diff --git a/CLAUDE.md b/CLAUDE.md
index 133087b..a806992 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,7 +38,7 @@ It's a **Clean Architecture** solution with two front-ends over one shared core.
- `Trackify.Cli` — a **Spectre.Console.Cli** console app to deploy on a Linux server / Raspberry Pi (plain `net10.0` → BlueZ via `AddLinuxLego`'s runtime check). On a Windows dev box it additionally targets `net10.0-windows10.0.19041.0`, where the `WINDOWS` symbol makes Application compile in `AddWindowsLego` (WinRT) — so `discover`/`drive` work on Windows too (run that TFM; the plain `net10.0` flavor has no Windows transport). `RuntimeIdentifiers=linux-arm64` (on the net10.0 flavor) so arm64 restore/publish works from Rider.
- `Trackify.Tests` — xUnit (pure-logic + store round-trip).
-- .NET 10, Uno.Sdk `6.5.36` (pinned in `global.json`; SDK pinned to `9.0.100` + `rollForward: latestMajor` → uses the newest installed major, net10 heads require the .NET 10 SDK). Update Uno in `global.json`, not in package props.
+- .NET 10, Uno.Sdk `6.5.36` (pinned in `global.json`; SDK pinned to `10.0.0` + `rollForward: latestMajor` → uses the newest installed major, net10 heads require the .NET 10 SDK). Update Uno in `global.json`, not in package props.
- **CI** (GitHub Actions, `.github/workflows/`): `ci.yml` is the pre-merge gate on PRs to `master` — an ubuntu job builds the CLI + shared core and runs the tests. The Uno app is deliberately not built in CI (its 5 heads need workloads + macOS/Windows even with `-f
`, because restore imports workloads for all TFMs); the Android head is covered by `android-apk.yml`. `android-apk.yml` builds the Android APK on windows-latest (JDK 17 + `android` workload, `-f net10.0-android`) on tag `v*` or manual dispatch; artifact `trackify-apk`. Both provision the .NET 8/9/10 SDKs.
- Uno heads: `net10.0-android`, `net10.0-ios`, `net10.0-browserwasm`, `net10.0-desktop`, `net10.0-windows10.0.19041.0`. The shared libs + CLI are plain `net10.0`.
- Central Package Management (`Directory.Packages.props`) — add versions there, reference without a version in the csproj.
@@ -89,7 +89,7 @@ CLI deployment (Pi publish — no build flags; `trains.json`/store schema, syste
- **Type-name suffix per folder:** `Services/` → `*Service`, `Presentation/ViewModels/` → `*ViewModel`, `Presentation/Behaviors/` → `*Behavior`, `Presentation/Widgets/` → `*Widget`.
- **Folder is `Pages`, not "Screens".**
- **`Components/`** = page-specific composed sections that inherit the page's `DataContext`. **`Widgets/`** = reusable atoms that expose `DependencyProperty`s (e.g. `SpeedProfileWidget.Graph`).
-- **No page code-behind** beyond `InitializeComponent()`, except the deliberate responsive master-detail layout in `Pages/MainPage.xaml.cs`. Use attached behaviors (e.g. `Behaviors/TappedCommandBehavior`) instead of `*_Tapped` handlers.
+- **No code-behind** beyond `InitializeComponent()` — for Pages *and* Components/Widgets alike — except the deliberate responsive master-detail layout in `Pages/MainPage.xaml.cs` and `Pages/SecondPage.xaml.cs`: both react to width *and* a selection change (`SelectedTrain`/`SelectedSegment`), which `AdaptiveTrigger`/`VisualStateManager` can't express declaratively, so it's hand-rolled in a `SizeChanged`/`Loaded`/`DataContextChanged` handler — nothing else lives in either file. Any other view-side interactivity goes through a plain `Command` binding, or — only when an event needs to reach a command that isn't natively `Command`-bindable (e.g. a tapped `Grid`/`StackPanel`) — an attached behavior in `Presentation/Behaviors/` (`TappedCommandBehavior`). Avoid imperative control APIs (`ScrollViewer.ChangeView` and similar) entirely where possible — prefer a bound value the ViewModel owns (e.g. the Streckenplaner's zoom is a `SecondViewModel.ZoomFactor` double driving a `Viewbox`'s bound `Width`/`Height`, not a `ScrollViewer` zoom call) — both because it's more MVVM-honest and because `ScrollViewer`'s programmatic zoom isn't reliably supported across every Uno target. Reach for a new attached behavior rather than a `*_Click`/`*_Tapped` handler in a `.xaml.cs` file.
- **MVVM** with CommunityToolkit.Mvvm: field-based `[ObservableProperty]` and `[RelayCommand]`. (`MVVMTK0045` partial-property advice is intentionally suppressed.)
- Converters are registered **once** globally in `Styles/Converters.xaml` (merged in `App.xaml`); don't re-declare per page. Design tokens/styles live in `Styles/DesignTokens.xaml`.
- Classic `{Binding}` views carry a design-time `d:DataContext="{d:DesignInstance ...}"` (with `mc:Ignorable="d"`) so binding paths resolve in the IDE. This is design-time only. Add one when creating a new view/data-template.
diff --git a/Source/Trackify.Application/Catalog/LegoinoCatalog.cs b/Source/Trackify.Application/Catalog/LegoinoCatalog.cs
index c33fbc0..926c3af 100644
--- a/Source/Trackify.Application/Catalog/LegoinoCatalog.cs
+++ b/Source/Trackify.Application/Catalog/LegoinoCatalog.cs
@@ -70,6 +70,16 @@ public static class LegoinoCatalog
new(SensorActionType.ReverseDirection, "Richtung wechseln"),
];
+ /// The Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve links/Kurve rechts/Weiche/Bahnhof).
+ public static readonly IReadOnlyList TrackParts =
+ [
+ new(SegmentType.Straight, null, "Gerade"),
+ new(SegmentType.Curve, CurveDirection.Left, "Kurve links"),
+ new(SegmentType.Curve, CurveDirection.Right, "Kurve rechts"),
+ new(SegmentType.Switch, null, "Weiche"),
+ new(SegmentType.Station, null, "Bahnhof"),
+ ];
+
public static HubOption Hub(HubType value) => Hubs.First(h => h.Value == value);
public static DeviceOption Device(DeviceType value) => Devices.First(d => d.Value == value);
public static ColorOption Color(LedColorType value) => Colors.First(c => c.Value == value);
diff --git a/Source/Trackify.Application/Catalog/TrackPartOption.cs b/Source/Trackify.Application/Catalog/TrackPartOption.cs
new file mode 100644
index 0000000..a06b414
--- /dev/null
+++ b/Source/Trackify.Application/Catalog/TrackPartOption.cs
@@ -0,0 +1,8 @@
+namespace Trackify.Application.Catalog;
+
+///
+/// Selectable track-part option for the Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve
+/// links/Kurve rechts/Weiche/Bahnhof) — the segment plus, for a curve, which
+/// way it bends.
+///
+public sealed record TrackPartOption(SegmentType Type, CurveDirection? Curve, string Label);
diff --git a/Source/Trackify.Application/DependencyInjection.cs b/Source/Trackify.Application/DependencyInjection.cs
index 257783e..49d5b8c 100644
--- a/Source/Trackify.Application/DependencyInjection.cs
+++ b/Source/Trackify.Application/DependencyInjection.cs
@@ -11,7 +11,7 @@ public static class DependencyInjection
{
///
/// Registers the Application use-case services (,
- /// ) and — filtered per platform right here in DI — the matching
+ /// , ) and — filtered per platform right here in DI — the matching
/// transport via its Add…Lego helper (mirroring the CLI's
/// AddLinuxLego): Android → AddAndroidLego, iOS → AddIosLego, Windows →
/// AddWindowsLego. The plain net10.0 flavor registers none — the composition root decides
@@ -21,6 +21,7 @@ public static IServiceCollection AddTrackifyApplication(this IServiceCollection
{
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
#if __ANDROID__
services.AddAndroidLego();
diff --git a/Source/Trackify.Application/Trains/ITrackPlanService.cs b/Source/Trackify.Application/Trains/ITrackPlanService.cs
new file mode 100644
index 0000000..7ed4857
--- /dev/null
+++ b/Source/Trackify.Application/Trains/ITrackPlanService.cs
@@ -0,0 +1,45 @@
+namespace Trackify.Application.Trains;
+
+/// Result of inserting a Weiche: the updated segment list plus the new branch strand's id.
+public sealed record SwitchInsertResult(IReadOnlyList Segments, Guid BranchStrandId);
+
+///
+/// Shared use-case for building a track plan: every "smart" rule from the Streckenplaner design
+/// (appending inherits speed/direction/f(x) from the predecessor, a Weiche spawns its own branch
+/// strand, templates lay out a whole loop in one step) lives here instead of the ViewModel — the same
+/// separation gives train control. Operates on plain
+/// lists; callers own persistence via .
+///
+public interface ITrackPlanService
+{
+ ///
+ /// Appends a new part to 's open (last) end. The new segment inherits
+ /// max speed, direction, and accel/brake functions from the strand's current last segment (or,
+ /// for a fresh branch strand, from the Weiche it branches off). is
+ /// required when is .
+ ///
+ IReadOnlyList AppendPart(
+ IReadOnlyList segments, Guid strandId, SegmentType type, CurveDirection? curve = null);
+
+ ///
+ /// Inserts a Weiche at 's open end and creates the branch strand it
+ /// peels off into (). The branch starts empty; the caller
+ /// decides whether to keep building on the trunk or switch the "active strand" to the branch.
+ ///
+ SwitchInsertResult InsertSwitch(IReadOnlyList segments, Guid strandId, CurveDirection branchDirection);
+
+ /// Replaces every strand with a canned layout (Oval/Acht/Punkt-zu-Punkt), all on the main strand.
+ IReadOnlyList ApplyTemplate(TemplateType template);
+
+ /// Duplicates one segment, inserting the copy immediately after it in the same strand.
+ IReadOnlyList Duplicate(IReadOnlyList segments, Guid segmentId);
+
+ /// Swaps a segment with its neighbour in travel order ( = toward the start).
+ IReadOnlyList Reorder(IReadOnlyList segments, Guid segmentId, bool up);
+
+ /// Removes a segment. Deleting a Weiche also removes the branch strand it anchors.
+ IReadOnlyList Delete(IReadOnlyList segments, Guid segmentId);
+
+ /// Empties the plan entirely ("Leeren").
+ IReadOnlyList Clear();
+}
diff --git a/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs b/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs
new file mode 100644
index 0000000..f47d3e2
--- /dev/null
+++ b/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs
@@ -0,0 +1,9 @@
+using Trackify.Application.Common;
+
+namespace Trackify.Application.Trains;
+
+///
+/// Repository for the planned track segments — mirrors : the default
+/// CRUD from is all the front-ends need today.
+///
+public interface ITrackSegmentRepository : IBaseRepository;
diff --git a/Source/Trackify.Application/Trains/TemplateType.cs b/Source/Trackify.Application/Trains/TemplateType.cs
new file mode 100644
index 0000000..fef3344
--- /dev/null
+++ b/Source/Trackify.Application/Trains/TemplateType.cs
@@ -0,0 +1,9 @@
+namespace Trackify.Application.Trains;
+
+/// A canned segment layout can generate in one step.
+public enum TemplateType
+{
+ Oval,
+ Acht,
+ PunktZuPunkt,
+}
diff --git a/Source/Trackify.Application/Trains/TrackPlanService.cs b/Source/Trackify.Application/Trains/TrackPlanService.cs
new file mode 100644
index 0000000..3781180
--- /dev/null
+++ b/Source/Trackify.Application/Trains/TrackPlanService.cs
@@ -0,0 +1,185 @@
+namespace Trackify.Application.Trains;
+
+///
+public sealed class TrackPlanService : ITrackPlanService
+{
+ public IReadOnlyList AppendPart(
+ IReadOnlyList segments, Guid strandId, SegmentType type, CurveDirection? curve = null)
+ {
+ var strand = StrandSorted(segments, strandId);
+ var predecessor = strand.Count > 0 ? strand[^1] : FindAnchor(segments, strandId);
+
+ var part = new TrackSegmentDto
+ {
+ Id = Guid.CreateVersion7(),
+ Name = NextName(segments, type, curve),
+ Type = type,
+ Curve = type == SegmentType.Curve ? curve ?? CurveDirection.Right : null,
+ MaxSpeed = predecessor?.MaxSpeed ?? 70,
+ Direction = predecessor?.Direction ?? TrackDirection.Forward,
+ AccelFn = predecessor?.AccelFn ?? SpeedFunctionType.EaseOut,
+ BrakeFn = predecessor?.BrakeFn ?? SpeedFunctionType.EaseIn,
+ StrandId = strandId,
+ Order = strand.Count,
+ };
+
+ return [.. segments, part];
+ }
+
+ public SwitchInsertResult InsertSwitch(IReadOnlyList segments, Guid strandId, CurveDirection branchDirection)
+ {
+ var appended = AppendPart(segments, strandId, SegmentType.Switch);
+ var switchSegment = appended[^1];
+ var branchStrandId = Guid.CreateVersion7();
+
+ var updated = appended
+ .Select(s => s.Id == switchSegment.Id ? s with { BranchStrandId = branchStrandId, BranchDirection = branchDirection } : s)
+ .ToList();
+
+ return new SwitchInsertResult(updated, branchStrandId);
+ }
+
+ public IReadOnlyList ApplyTemplate(TemplateType template)
+ {
+ var parts = template switch
+ {
+ TemplateType.Oval => OvalParts,
+ TemplateType.Acht => OvalParts.Concat(MirroredOvalParts),
+ TemplateType.PunktZuPunkt => PunktZuPunktParts,
+ _ => [],
+ };
+
+ var segments = new List();
+ var order = 0;
+ foreach (var (type, curve) in parts)
+ {
+ segments.Add(new TrackSegmentDto
+ {
+ Id = Guid.CreateVersion7(),
+ Name = NextName(segments, type, curve),
+ Type = type,
+ Curve = curve,
+ MaxSpeed = 70,
+ StrandId = Guid.Empty,
+ Order = order++,
+ });
+ }
+
+ return segments;
+ }
+
+ public IReadOnlyList Duplicate(IReadOnlyList segments, Guid segmentId)
+ {
+ var source = segments.First(s => s.Id == segmentId);
+ var strand = StrandSorted(segments, source.StrandId);
+ var index = strand.FindIndex(s => s.Id == segmentId);
+
+ strand.Insert(index + 1, source with { Id = Guid.CreateVersion7(), Name = source.Name + " (Kopie)" });
+
+ return ReplaceStrand(segments, source.StrandId, strand);
+ }
+
+ public IReadOnlyList Reorder(IReadOnlyList segments, Guid segmentId, bool up)
+ {
+ var source = segments.First(s => s.Id == segmentId);
+ var strand = StrandSorted(segments, source.StrandId);
+ var index = strand.FindIndex(s => s.Id == segmentId);
+ var swapWith = up ? index - 1 : index + 1;
+
+ if (swapWith < 0 || swapWith >= strand.Count) return segments;
+
+ (strand[index], strand[swapWith]) = (strand[swapWith], strand[index]);
+
+ return ReplaceStrand(segments, source.StrandId, strand);
+ }
+
+ public IReadOnlyList Delete(IReadOnlyList segments, Guid segmentId)
+ {
+ var source = segments.First(s => s.Id == segmentId);
+ var remaining = segments.Where(s => s.Id != segmentId).ToList();
+
+ if (source.Type == SegmentType.Switch && source.BranchStrandId is { } branchId)
+ {
+ remaining = RemoveStrandCascade(remaining, branchId);
+ }
+
+ var strand = StrandSorted(remaining, source.StrandId);
+ return ReplaceStrand(remaining, source.StrandId, strand);
+ }
+
+ public IReadOnlyList Clear() => [];
+
+ // Every strand's open end to append to: its own last segment by Order, or — for a fresh, empty
+ // branch strand — the Weiche that anchors it (so the branch's first part still inherits speed/
+ // direction/f(x), matching "erbt ... vom Vorgänger" even before the branch has segments of its own).
+ private static TrackSegmentDto? FindAnchor(IReadOnlyList segments, Guid strandId)
+ => strandId == Guid.Empty ? null : segments.FirstOrDefault(s => s.Type == SegmentType.Switch && s.BranchStrandId == strandId);
+
+ private static List StrandSorted(IReadOnlyList segments, Guid strandId)
+ => [.. segments.Where(s => s.StrandId == strandId).OrderBy(s => s.Order)];
+
+ // Renumbers the given strand's Order to its list position and splices it back into the full set.
+ private static IReadOnlyList ReplaceStrand(IReadOnlyList segments, Guid strandId, List strand)
+ {
+ for (var i = 0; i < strand.Count; i++) strand[i] = strand[i] with { Order = i };
+ return [.. segments.Where(s => s.StrandId != strandId), .. strand];
+ }
+
+ // Deleting a Weiche takes its whole branch with it, including any further Weichen nested inside.
+ private static List RemoveStrandCascade(List segments, Guid strandId)
+ {
+ var branchSegments = segments.Where(s => s.StrandId == strandId).ToList();
+ var result = segments.Where(s => s.StrandId != strandId).ToList();
+
+ foreach (var segment in branchSegments)
+ {
+ if (segment.Type == SegmentType.Switch && segment.BranchStrandId is { } nestedBranchId)
+ {
+ result = RemoveStrandCascade(result, nestedBranchId);
+ }
+ }
+
+ return result;
+ }
+
+ private static string NextName(IReadOnlyList segments, SegmentType type, CurveDirection? curve)
+ => $"{PartLabel(type, curve)} {segments.Count(s => s.Type == type && s.Curve == curve) + 1}";
+
+ private static string PartLabel(SegmentType type, CurveDirection? curve) => type switch
+ {
+ SegmentType.Straight => "Gerade",
+ SegmentType.Curve => curve == CurveDirection.Left ? "Kurve links" : "Kurve rechts",
+ SegmentType.Switch => "Weiche",
+ SegmentType.Station => "Bahnhof",
+ _ => "Segment",
+ };
+
+ // A running oval: half straight, half station (the "top" straight split at its midpoint, matching
+ // the original hard-coded seed), a 180° turn (two 90° curve pieces), the "bottom" straight (also
+ // split in two), then the opposite 180° turn back to the start heading.
+ private static readonly (SegmentType Type, CurveDirection? Curve)[] OvalParts =
+ [
+ (SegmentType.Straight, null), (SegmentType.Station, null),
+ (SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right),
+ (SegmentType.Straight, null), (SegmentType.Straight, null),
+ (SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right),
+ ];
+
+ // The second lobe, turning the other way. OvalParts ends back at the start pose, so this one has to
+ // *lead* with its 180° turn: leading with straights (like OvalParts does) would retrace the first
+ // lobe's opening straight and station piece-for-piece — two segments stacked at identical
+ // coordinates. Turning away first and closing with the straights instead puts the lobe on the far
+ // side of the shared point, which is what makes the pair read as a figure-eight.
+ private static readonly (SegmentType Type, CurveDirection? Curve)[] MirroredOvalParts =
+ [
+ (SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left),
+ (SegmentType.Straight, null), (SegmentType.Straight, null),
+ (SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left),
+ (SegmentType.Straight, null), (SegmentType.Station, null),
+ ];
+
+ private static readonly (SegmentType Type, CurveDirection? Curve)[] PunktZuPunktParts =
+ [
+ (SegmentType.Station, null), (SegmentType.Straight, null), (SegmentType.Straight, null), (SegmentType.Station, null),
+ ];
+}
diff --git a/Source/Trackify.Application/Trains/TrackSegmentDto.cs b/Source/Trackify.Application/Trains/TrackSegmentDto.cs
new file mode 100644
index 0000000..5021578
--- /dev/null
+++ b/Source/Trackify.Application/Trains/TrackSegmentDto.cs
@@ -0,0 +1,26 @@
+namespace Trackify.Application.Trains;
+
+///
+/// Data-transfer view of a saved track segment. Mirrors 's boundary role: the
+/// Domain entity never leaks past the use-case layer. Map with .
+///
+public sealed record TrackSegmentDto
+{
+ public Guid Id { get; init; }
+ public string Name { get; set; } = "";
+ public SegmentType Type { get; set; } = SegmentType.Straight;
+ public int MaxSpeed { get; set; } = 70;
+ public TrackDirection Direction { get; set; } = TrackDirection.Forward;
+ public SpeedFunctionType AccelFn { get; set; } = SpeedFunctionType.EaseOut;
+ public SpeedFunctionType BrakeFn { get; set; } = SpeedFunctionType.EaseIn;
+ public SensorType Sensor { get; set; } = SensorType.None;
+ public SensorActionType Action { get; set; } = SensorActionType.Notify;
+ public int SlowTarget { get; set; } = 30;
+ public Guid StrandId { get; set; } = Guid.Empty;
+ public int Order { get; set; }
+ public CurveDirection? Curve { get; set; }
+ public Guid? BranchStrandId { get; set; }
+ public CurveDirection? BranchDirection { get; set; }
+ public SwitchRoute Route { get; set; } = SwitchRoute.Main;
+ public Guid? BranchLinkSegmentId { get; set; }
+}
diff --git a/Source/Trackify.Application/Trains/TrackSegmentMapping.cs b/Source/Trackify.Application/Trains/TrackSegmentMapping.cs
new file mode 100644
index 0000000..5635053
--- /dev/null
+++ b/Source/Trackify.Application/Trains/TrackSegmentMapping.cs
@@ -0,0 +1,47 @@
+namespace Trackify.Application.Trains;
+
+/// Maps between the Domain entity and the boundary .
+public static class TrackSegmentMapping
+{
+ public static TrackSegmentDto ToDto(this TrackSegment segment) => new()
+ {
+ Id = segment.Id,
+ Name = segment.Name,
+ Type = segment.Type,
+ MaxSpeed = segment.MaxSpeed,
+ Direction = segment.Direction,
+ AccelFn = segment.AccelFn,
+ BrakeFn = segment.BrakeFn,
+ Sensor = segment.Sensor,
+ Action = segment.Action,
+ SlowTarget = segment.SlowTarget,
+ StrandId = segment.StrandId,
+ Order = segment.Order,
+ Curve = segment.Curve,
+ BranchStrandId = segment.BranchStrandId,
+ BranchDirection = segment.BranchDirection,
+ Route = segment.Route,
+ BranchLinkSegmentId = segment.BranchLinkSegmentId,
+ };
+
+ public static TrackSegment ToEntity(this TrackSegmentDto dto) => new()
+ {
+ Id = dto.Id,
+ Name = dto.Name,
+ Type = dto.Type,
+ MaxSpeed = dto.MaxSpeed,
+ Direction = dto.Direction,
+ AccelFn = dto.AccelFn,
+ BrakeFn = dto.BrakeFn,
+ Sensor = dto.Sensor,
+ Action = dto.Action,
+ SlowTarget = dto.SlowTarget,
+ StrandId = dto.StrandId,
+ Order = dto.Order,
+ Curve = dto.Curve,
+ BranchStrandId = dto.BranchStrandId,
+ BranchDirection = dto.BranchDirection,
+ Route = dto.Route,
+ BranchLinkSegmentId = dto.BranchLinkSegmentId,
+ };
+}
diff --git a/Source/Trackify.Domain/Enums/CurveDirection.cs b/Source/Trackify.Domain/Enums/CurveDirection.cs
new file mode 100644
index 0000000..805230f
--- /dev/null
+++ b/Source/Trackify.Domain/Enums/CurveDirection.cs
@@ -0,0 +1,7 @@
+namespace Trackify.Domain.Enums;
+
+public enum CurveDirection
+{
+ Left,
+ Right,
+}
diff --git a/Source/Trackify.Domain/Enums/SegmentType.cs b/Source/Trackify.Domain/Enums/SegmentType.cs
index 49ecb1a..a21c38f 100644
--- a/Source/Trackify.Domain/Enums/SegmentType.cs
+++ b/Source/Trackify.Domain/Enums/SegmentType.cs
@@ -5,4 +5,5 @@ public enum SegmentType
Straight,
Curve,
Station,
+ Switch,
}
diff --git a/Source/Trackify.Domain/Enums/SwitchRoute.cs b/Source/Trackify.Domain/Enums/SwitchRoute.cs
new file mode 100644
index 0000000..93bdc0b
--- /dev/null
+++ b/Source/Trackify.Domain/Enums/SwitchRoute.cs
@@ -0,0 +1,8 @@
+namespace Trackify.Domain.Enums;
+
+/// Which way a segment is currently thrown.
+public enum SwitchRoute
+{
+ Main,
+ Branch,
+}
diff --git a/Source/Trackify.Domain/Trains/TrackSegment.cs b/Source/Trackify.Domain/Trains/TrackSegment.cs
index 98d8c66..fbe5752 100644
--- a/Source/Trackify.Domain/Trains/TrackSegment.cs
+++ b/Source/Trackify.Domain/Trains/TrackSegment.cs
@@ -1,14 +1,18 @@
-
namespace Trackify.Domain.Trains;
///
/// The persisted, transport-agnostic configuration of one track segment (drive behaviour + sensor).
/// Pure data: the canvas geometry/SVG and German labels that the planner renders live in the
/// presentation layer, not here.
+///
+/// Segments form strands: an ordered chain ( + ) that a
+/// part is always appended to at its open (last) end. A segment is
+/// the trunk continuing straight through the switch point *and* the anchor a second, branch strand
+/// () starts from — so a switch never needs its own geometry, only a
+/// pointer to where its branch begins.
///
-public sealed record TrackSegment
+public sealed record TrackSegment : BaseEntity
{
- public string Id { get; set; } = "";
public string Name { get; set; } = "";
public SegmentType Type { get; set; } = SegmentType.Straight;
public int MaxSpeed { get; set; } = 70;
@@ -18,4 +22,26 @@ public sealed record TrackSegment
public SensorType Sensor { get; set; } = SensorType.None;
public SensorActionType Action { get; set; } = SensorActionType.Notify;
public int SlowTarget { get; set; } = 30;
+
+ /// Which strand this segment belongs to. The main strand uses .
+ public Guid StrandId { get; set; } = Guid.Empty;
+
+ /// Position within , 0-based in travel order.
+ public int Order { get; set; }
+
+ /// Set when is : which way it bends.
+ public CurveDirection? Curve { get; set; }
+
+ /// Switch-only: the strand id of the branch this switch creates.
+ public Guid? BranchStrandId { get; set; }
+
+ /// Switch-only: which way the branch peels off from the trunk.
+ public CurveDirection? BranchDirection { get; set; }
+
+ /// Switch-only: which route is currently thrown ("Gestellt auf" in the planner).
+ public SwitchRoute Route { get; set; } = SwitchRoute.Main;
+
+ /// Switch-only, optional: the segment in another strand the branch's open end connects
+ /// back to, closing a loop ("Zweig-Ende anschließen an" in the planner).
+ public Guid? BranchLinkSegmentId { get; set; }
}
diff --git a/Source/Trackify.Infrastructure/DependencyInjection.cs b/Source/Trackify.Infrastructure/DependencyInjection.cs
index fa3346e..3dd5782 100644
--- a/Source/Trackify.Infrastructure/DependencyInjection.cs
+++ b/Source/Trackify.Infrastructure/DependencyInjection.cs
@@ -24,6 +24,7 @@ public static IServiceCollection AddTrackifyInfrastructure(this IServiceCollecti
services.AddDbContextFactory(options => options.UseSqlite($"Data Source={resolvedPath}"));
services.AddSingleton();
+ services.AddSingleton();
services.AddLinuxLego();
return services;
diff --git a/Source/Trackify.Infrastructure/Persistence/SqliteTrackSegmentRepository.cs b/Source/Trackify.Infrastructure/Persistence/SqliteTrackSegmentRepository.cs
new file mode 100644
index 0000000..4401c71
--- /dev/null
+++ b/Source/Trackify.Infrastructure/Persistence/SqliteTrackSegmentRepository.cs
@@ -0,0 +1,11 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Trackify.Infrastructure.Persistence;
+
+///
+/// EF Core + SQLite repository for the planned track segments — the default CRUD comes from
+/// , mirroring . Same
+/// trackify.db as the trains, so both are readable from the CLI and the app.
+///
+public sealed class SqliteTrackSegmentRepository(IDbContextFactory dbContextFactory)
+ : BaseRepository(dbContextFactory), ITrackSegmentRepository;
diff --git a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs
index 257320f..1825ea8 100644
--- a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs
+++ b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs
@@ -10,8 +10,13 @@ public sealed class TrackifyDbContext(DbContextOptions option
{
public DbSet Trains => Set();
+ public DbSet TrackSegments => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
- => modelBuilder.Entity().HasKey(train => train.Id);
+ {
+ modelBuilder.Entity().HasKey(train => train.Id);
+ modelBuilder.Entity().HasKey(segment => segment.Id);
+ }
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
=> configurationBuilder.Properties().HaveConversion();
diff --git a/Source/Trackify/Helpers/TrackGeometry.cs b/Source/Trackify/Helpers/TrackGeometry.cs
index 18d834b..ed841c0 100644
--- a/Source/Trackify/Helpers/TrackGeometry.cs
+++ b/Source/Trackify/Helpers/TrackGeometry.cs
@@ -1,146 +1,296 @@
-using System.Globalization;
-using System.Text;
-
namespace Trackify.Helpers;
-/// Builds the static stadium-shaped 8-segment track layout (900x600 canvas) used by the Streckenplaner.
+///
+/// Lays out an arbitrary, user-built track (straights/curves/stations/switches, branching at
+/// switches into their own strands) on the shared 900x600 canvas — turtle graphics: walk each
+/// strand's pieces in order from an anchor pose (position + heading), each piece advancing the pose.
+/// A branch strand's anchor is its switch's end pose, rotated by the branch's peel-off direction, so
+/// switches never need geometry of their own beyond a short stub.
+/// Two passes, because a plan grows wherever the user builds and how far it reaches is only known
+/// once every strand is walked: the walk collects each piece's shape in unbounded world coordinates
+/// from a (0,0) anchor, then the emit pass bakes in the scale/offset that centers the finished plan
+/// on the canvas — so nothing can walk off the visible area, and everything the canvas needs is a
+/// plain, ready-to-draw coordinate (no data-bound render transforms, which don't inherit a
+/// DataContext on every platform).
+/// Replaces the previous closed-form fixed 8-segment stadium loop now that the Streckenplaner has
+/// real CRUD instead of one hard-coded layout.
+///
public static class TrackGeometry
{
- private const double StraightLength = 400;
- private const double CurveRadius = 180;
- private static readonly double ArcLength = Math.PI * CurveRadius;
- private static readonly double TotalLength = 2 * StraightLength + 2 * ArcLength;
-
- // Segment ids, in travel order around the loop. Named constants so the four tables below
- // (ids, names, types, arc-length ranges) can't drift apart on a typo.
- public const string Seg1 = "SEG-1";
- public const string Seg2 = "SEG-2";
- public const string Seg3 = "SEG-3";
- public const string Seg4 = "SEG-4";
- public const string Seg5 = "SEG-5";
- public const string Seg6 = "SEG-6";
- public const string Seg7 = "SEG-7";
- public const string Seg8 = "SEG-8";
-
- public static readonly IReadOnlyList SegmentIds =
- [
- Seg1, Seg2, Seg3, Seg4, Seg5, Seg6, Seg7, Seg8,
- ];
-
- public static readonly IReadOnlyDictionary Names = new Dictionary
- {
- [Seg1] = "Gerade Nordwest",
- [Seg2] = "Bahnhof Nord",
- [Seg3] = "Kurve Ost (oben)",
- [Seg4] = "Kurve Ost (unten)",
- [Seg5] = "Gerade Südost",
- [Seg6] = "Gerade Südwest",
- [Seg7] = "Kurve West (unten)",
- [Seg8] = "Kurve West (oben)",
- };
+ private const double StraightLength = 90;
+ private const double StationLength = 90;
+ private const double SwitchLength = 55;
+ private const double CurveRadius = 70;
+ private const double Trim = 6;
+ private const double CanvasWidth = 900;
+ private const double CanvasHeight = 600;
+
+ /// Kept free on every side of the fitted track, so the ballast stroke and the label/sensor
+ /// markers hanging off the centerline stay on the canvas — see TrackSegment.ApplyGeometry
+ /// for the outward offsets this has to cover.
+ private const double Margin = 70;
+
+ /// Vertical spacing between strands parked below the plan by the orphan pass.
+ private const double OrphanLaneGap = 170;
- public static readonly IReadOnlyDictionary Types = new Dictionary
+ /// Points sampled along an arc to bound it — enough that the fit never clips a curve's
+ /// bulge (a quarter of a 70-radius circle deviates well under a pixel between samples).
+ private const int ArcSamples = 8;
+
+ private static readonly double QuarterTurn = Math.PI / 2;
+ private static readonly double BranchPeel = Math.PI / 6;
+
+ public static TrackLayout Build(IReadOnlyList segments)
{
- [Seg1] = SegmentType.Straight,
- [Seg2] = SegmentType.Station,
- [Seg3] = SegmentType.Curve,
- [Seg4] = SegmentType.Curve,
- [Seg5] = SegmentType.Straight,
- [Seg6] = SegmentType.Straight,
- [Seg7] = SegmentType.Curve,
- [Seg8] = SegmentType.Curve,
- };
+ var pieces = Walk(segments);
+ var view = Fit(pieces);
+ var result = new Dictionary(pieces.Count);
+
+ foreach (var piece in pieces) result[piece.Id] = Emit(piece, view);
- public static string BuildTrackBed()
+ return new TrackLayout(
+ result,
+ // The ballast layer behind the colored track — every piece's own path, as separate subpaths
+ // (each starting with its own "M") within one Data string, drawn with a thick stroke.
+ string.Join(" ", result.Values.Select(g => g.PathData)));
+ }
+
+ private static List Walk(IReadOnlyList segments)
{
- var n = (int)Math.Ceiling(TotalLength / 8);
- var sb = new StringBuilder();
- for (var i = 0; i <= n; i++)
+ var byStrand = segments.GroupBy(s => s.StrandId).ToDictionary(g => g.Key, g => g.OrderBy(s => s.Order).ToList());
+ var pieces = new List(segments.Count);
+ var anchors = new Dictionary { [Guid.Empty] = new(0, 0, 0) };
+ var pending = new Queue([Guid.Empty]);
+ var visited = new HashSet();
+ var lane = 0;
+
+ while (true)
{
- var p = PointAt(TotalLength * i / n);
- AppendPoint(sb, i > 0, p.X, p.Y);
+ while (pending.Count > 0)
+ {
+ var strandId = pending.Dequeue();
+ if (!visited.Add(strandId) || !byStrand.TryGetValue(strandId, out var strand)) continue;
+
+ var pose = anchors[strandId];
+ foreach (var segment in strand)
+ {
+ var piece = segment.Type == SegmentType.Curve
+ ? ArcPiece(segment.Id, pose, segment.Curve == CurveDirection.Left ? -1.0 : 1.0)
+ : LinePiece(segment.Id, pose, LengthOf(segment.Type));
+
+ pieces.Add(piece);
+ pose = piece.End;
+
+ if (segment.Type == SegmentType.Switch && segment.BranchStrandId is { } branchId)
+ {
+ var peelSign = segment.BranchDirection == CurveDirection.Left ? -1.0 : 1.0;
+ anchors[branchId] = pose with { Heading = pose.Heading + (peelSign * BranchPeel) };
+ pending.Enqueue(branchId);
+ }
+ }
+ }
+
+ // Orphan pass — a strand nothing branches into (a branch whose Weiche is missing, e.g. from a
+ // plan saved mid-write) is unreachable from the main anchor. Without this it would get no
+ // geometry at all and keep rendering wherever the previous layout happened to leave it, so it
+ // is parked on its own lane below the plan: visible, and therefore deletable.
+ // Guid.Empty is always visited by the first inner pass, so it doubles as "nothing left".
+ var orphanStrandId = byStrand.Keys.FirstOrDefault(id => !visited.Contains(id));
+ if (orphanStrandId == Guid.Empty) return pieces;
+
+ anchors[orphanStrandId] = new Pose(0, ++lane * OrphanLaneGap, 0);
+ pending.Enqueue(orphanStrandId);
}
- sb.Append(" Z");
- return sb.ToString();
}
- public static IReadOnlyList BuildSegments()
+ /// Centers the plan's bounding box on the canvas, shrinking it (never enlarging) when it
+ /// would otherwise reach into the margin reserved for the labels and sensor markers.
+ private static View Fit(List pieces)
{
- var arc = ArcLength;
- var l = StraightLength;
- var defs = new (string Id, double A, double B)[]
- {
- (Seg1, 0, l / 2), (Seg2, l / 2, l),
- (Seg3, l, l + arc / 2), (Seg4, l + arc / 2, l + arc),
- (Seg5, l + arc, l + arc + l / 2), (Seg6, l + arc + l / 2, 2 * l + arc),
- (Seg7, 2 * l + arc, 2 * l + arc + arc / 2), (Seg8, 2 * l + arc + arc / 2, TotalLength),
- };
-
- var result = new List(defs.Length);
- foreach (var (id, a, b) in defs)
- {
- var mid = PointAt((a + b) / 2);
- result.Add(new SegmentGeometry(id, Build(a, b, 12, 8), Build(a, b, 0, 12), mid.X, mid.Y, mid.OutX, mid.OutY, mid.TanX, mid.TanY));
- }
- return result;
+ if (pieces.Count == 0) return new View(1, CanvasWidth / 2, CanvasHeight / 2);
+
+ var extent = pieces.Select(p => p.Extent).Aggregate((a, b) => a.Union(b));
+ var scale = Math.Min(1, Math.Min(
+ (CanvasWidth - (2 * Margin)) / Math.Max(extent.MaxX - extent.MinX, 1),
+ (CanvasHeight - (2 * Margin)) / Math.Max(extent.MaxY - extent.MinY, 1)));
+
+ return new View(
+ scale,
+ (CanvasWidth / 2) - (scale * (extent.MinX + extent.MaxX) / 2),
+ (CanvasHeight / 2) - (scale * (extent.MinY + extent.MaxY) / 2));
}
- private static TrackPoint PointAt(double d)
+ private static SegmentGeometry Emit(Piece piece, View view)
{
- d = ((d % TotalLength) + TotalLength) % TotalLength;
- double x, y, ox, oy, tx, ty;
- if (d <= StraightLength)
- {
- x = 250 + d; y = 120; ox = 0; oy = -1; tx = 1; ty = 0;
- }
- else if (d <= StraightLength + ArcLength)
- {
- var th = -Math.PI / 2 + (d - StraightLength) / CurveRadius;
- x = 650 + CurveRadius * Math.Cos(th); y = 300 + CurveRadius * Math.Sin(th);
- ox = Math.Cos(th); oy = Math.Sin(th); tx = -Math.Sin(th); ty = Math.Cos(th);
- }
- else if (d <= 2 * StraightLength + ArcLength)
- {
- var dd = d - StraightLength - ArcLength;
- x = 650 - dd; y = 480; ox = 0; oy = 1; tx = -1; ty = 0;
- }
- else
+ var tanX = Math.Cos(piece.MidHeading);
+ var tanY = Math.Sin(piece.MidHeading);
+ var (pathData, hitPathData) = piece.Arc is { } arc ? ArcPaths(arc, view) : LinePaths(piece.Line, view);
+
+ return new SegmentGeometry(
+ piece.Id, pathData, hitPathData,
+ MidX: view.X(piece.MidX), MidY: view.Y(piece.MidY),
+ // The left-hand normal of the travel direction — for straights *and* curves, so labels and
+ // sensor markers stay on the same side of the track throughout. (Deriving a curve's normal
+ // from "away from the arc center" instead flips it between left and right curves.)
+ OutwardX: tanY, OutwardY: -tanX,
+ TanX: tanX, TanY: tanY);
+ }
+
+ private static double LengthOf(SegmentType type) => type switch
+ {
+ SegmentType.Station => StationLength,
+ SegmentType.Switch => SwitchLength,
+ _ => StraightLength,
+ };
+
+ private static Piece LinePiece(Guid id, Pose start, double length)
+ {
+ var end = new Pose(
+ start.X + (Math.Cos(start.Heading) * length),
+ start.Y + (Math.Sin(start.Heading) * length),
+ start.Heading);
+
+ return new Piece(
+ id, end,
+ MidX: (start.X + end.X) / 2, MidY: (start.Y + end.Y) / 2, MidHeading: start.Heading,
+ Extent: Extent.Of([(start.X, start.Y), (end.X, end.Y)]),
+ Line: new LineShape(start.X, start.Y, end.X, end.Y),
+ Arc: null);
+ }
+
+ private static Piece ArcPiece(Guid id, Pose start, double sign)
+ {
+ var cx = start.X + (CurveRadius * Math.Cos(start.Heading + (sign * QuarterTurn)));
+ var cy = start.Y + (CurveRadius * Math.Sin(start.Heading + (sign * QuarterTurn)));
+ var startAngle = Math.Atan2(start.Y - cy, start.X - cx);
+ var endAngle = startAngle + (sign * QuarterTurn);
+ var midAngle = startAngle + (sign * QuarterTurn / 2);
+ var end = new Pose(
+ cx + (CurveRadius * Math.Cos(endAngle)),
+ cy + (CurveRadius * Math.Sin(endAngle)),
+ start.Heading + (sign * QuarterTurn));
+
+ return new Piece(
+ id, end,
+ MidX: cx + (CurveRadius * Math.Cos(midAngle)), MidY: cy + (CurveRadius * Math.Sin(midAngle)),
+ MidHeading: start.Heading + (sign * QuarterTurn / 2),
+ Extent: ArcExtent(cx, cy, startAngle, endAngle),
+ Line: null,
+ Arc: new ArcShape(cx, cy, startAngle, endAngle, sign));
+ }
+
+ // Drawn path and (untrimmed, wider-stroked) hit path. The trim gap that separates neighboring
+ // pieces is applied after fitting, so it stays the same few pixels at any scale.
+ private static (string PathData, string HitPathData) LinePaths(LineShape? shape, View view)
+ {
+ var line = shape!.Value;
+ var (x0, y0) = (view.X(line.X0), view.Y(line.Y0));
+ var (x1, y1) = (view.X(line.X1), view.Y(line.Y1));
+
+ return (LinePath(x0, y0, x1, y1, Trim), LinePath(x0, y0, x1, y1, 0));
+ }
+
+ private static (string PathData, string HitPathData) ArcPaths(ArcShape arc, View view)
+ {
+ var (cx, cy) = (view.X(arc.Cx), view.Y(arc.Cy));
+ var radius = CurveRadius * view.Scale;
+ var trimAngle = Trim / radius;
+
+ return (
+ ArcPath(cx, cy, radius, arc.StartAngle + (arc.Sign * trimAngle), arc.EndAngle - (arc.Sign * trimAngle), arc.Sign),
+ ArcPath(cx, cy, radius, arc.StartAngle, arc.EndAngle, arc.Sign));
+ }
+
+ private static string LinePath(double x0, double y0, double x1, double y1, double trim)
+ {
+ if (trim > 0)
{
- var th = Math.PI / 2 + (d - 2 * StraightLength - ArcLength) / CurveRadius;
- x = 250 + CurveRadius * Math.Cos(th); y = 300 + CurveRadius * Math.Sin(th);
- ox = Math.Cos(th); oy = Math.Sin(th); tx = -Math.Sin(th); ty = Math.Cos(th);
+ var len = Math.Sqrt(((x1 - x0) * (x1 - x0)) + ((y1 - y0) * (y1 - y0)));
+ if (len > trim * 2)
+ {
+ var t = trim / len;
+ (x0, y0, x1, y1) = (x0 + ((x1 - x0) * t), y0 + ((y1 - y0) * t), x1 - ((x1 - x0) * t), y1 - ((y1 - y0) * t));
+ }
}
- return new TrackPoint(x, y, ox, oy, tx, ty);
+ return FormattableString.Invariant($"M {x0:0.0} {y0:0.0} L {x1:0.0} {y1:0.0}");
}
- private static string Build(double a, double b, double trim, double step)
+ private static string ArcPath(double cx, double cy, double radius, double startAngle, double endAngle, double sign)
{
- var s = a + trim;
- var e = b - trim;
- var n = Math.Max(1, (int)Math.Ceiling((e - s) / step));
- var sb = new StringBuilder();
- for (var i = 0; i <= n; i++)
+ var sx = cx + (radius * Math.Cos(startAngle));
+ var sy = cy + (radius * Math.Sin(startAngle));
+ var ex = cx + (radius * Math.Cos(endAngle));
+ var ey = cy + (radius * Math.Sin(endAngle));
+ var sweep = sign > 0 ? 1 : 0;
+ return FormattableString.Invariant(
+ $"M {sx:0.0} {sy:0.0} A {radius:0.0},{radius:0.0} 0 0,{sweep} {ex:0.0} {ey:0.0}");
+ }
+
+ private static Extent ArcExtent(double cx, double cy, double startAngle, double endAngle)
+ {
+ var points = new (double X, double Y)[ArcSamples + 1];
+ for (var i = 0; i <= ArcSamples; i++)
{
- var p = PointAt(s + (e - s) * i / n);
- AppendPoint(sb, i > 0, p.X, p.Y);
+ var angle = startAngle + ((endAngle - startAngle) * i / ArcSamples);
+ points[i] = (cx + (CurveRadius * Math.Cos(angle)), cy + (CurveRadius * Math.Sin(angle)));
}
- return sb.ToString();
+
+ return Extent.Of(points);
}
- private static void AppendPoint(StringBuilder sb, bool isLineTo, double x, double y)
+ private readonly record struct Pose(double X, double Y, double Heading);
+
+ /// One walked piece in world coordinates: where it leaves the pose, where its markers go,
+ /// what it covers, and the shape to emit — exactly one of /.
+ [ImplicitKeys(IsEnabled = false)]
+ private readonly record struct Piece(
+ Guid Id,
+ Pose End,
+ double MidX,
+ double MidY,
+ double MidHeading,
+ Extent Extent,
+ LineShape? Line,
+ ArcShape? Arc);
+
+ private readonly record struct LineShape(double X0, double Y0, double X1, double Y1);
+
+ private readonly record struct ArcShape(double Cx, double Cy, double StartAngle, double EndAngle, double Sign);
+
+ /// World-to-canvas transform from the fit pass.
+ private readonly record struct View(double Scale, double OffsetX, double OffsetY)
{
- sb.Append(isLineTo ? " L " : "M ");
- sb.Append(x.ToString("0.0", CultureInfo.InvariantCulture));
- sb.Append(' ');
- sb.Append(y.ToString("0.0", CultureInfo.InvariantCulture));
+ public double X(double worldX) => (worldX * Scale) + OffsetX;
+
+ public double Y(double worldY) => (worldY * Scale) + OffsetY;
}
- private readonly record struct TrackPoint(double X, double Y, double OutX, double OutY, double TanX, double TanY);
+ private readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY)
+ {
+ public static Extent Of(IReadOnlyList<(double X, double Y)> points) => new(
+ points.Min(p => p.X), points.Min(p => p.Y), points.Max(p => p.X), points.Max(p => p.Y));
+
+ public Extent Union(Extent other) => new(
+ Math.Min(MinX, other.MinX), Math.Min(MinY, other.MinY),
+ Math.Max(MaxX, other.MaxX), Math.Max(MaxY, other.MaxY));
+ }
}
+/// The subset of a track segment's fields needs to lay it out —
+/// decoupled from the presentation model so the geometry engine has no upward dependency.
+[ImplicitKeys(IsEnabled = false)]
+public readonly record struct TrackGeometryInput(
+ Guid Id,
+ SegmentType Type,
+ CurveDirection? Curve,
+ Guid StrandId,
+ int Order,
+ Guid? BranchStrandId,
+ CurveDirection? BranchDirection);
+
[ImplicitKeys(IsEnabled = false)]
public readonly record struct SegmentGeometry(
- string Id,
+ Guid Id,
string PathData,
string HitPathData,
double MidX,
@@ -148,6 +298,4 @@ public readonly record struct SegmentGeometry(
double OutwardX,
double OutwardY,
double TanX,
- double TanY)
-{
-}
+ double TanY);
diff --git a/Source/Trackify/Helpers/TrackLayout.cs b/Source/Trackify/Helpers/TrackLayout.cs
new file mode 100644
index 0000000..508d438
--- /dev/null
+++ b/Source/Trackify/Helpers/TrackLayout.cs
@@ -0,0 +1,8 @@
+namespace Trackify.Helpers;
+
+/// A finished layout pass: every segment's geometry, already fitted to the canvas, plus the
+/// ballast path drawn behind them all.
+[ImplicitKeys(IsEnabled = false)]
+public readonly record struct TrackLayout(
+ IReadOnlyDictionary Segments,
+ string BedPathData);
diff --git a/Source/Trackify/Models/Trains/TrackSegment.cs b/Source/Trackify/Models/Trains/TrackSegment.cs
index 921e514..065c5ba 100644
--- a/Source/Trackify/Models/Trains/TrackSegment.cs
+++ b/Source/Trackify/Models/Trains/TrackSegment.cs
@@ -3,7 +3,20 @@ namespace Trackify.Models.Trains;
public partial class TrackSegment : ObservableObject
{
- [ObservableProperty] private string id = "";
+ // Canvas marker boxes — TrackCanvas.xaml draws elements of exactly these sizes, and TrackGeometry's
+ // fit padding keeps the largest outward offset on screen.
+ private const double LabelWidth = 40;
+ private const double LabelHeight = 21;
+ private const double LabelOffset = 40;
+ private const double SensorSize = 30;
+ private const double SensorOffset = 50;
+ private const double ArrowLength = 16;
+ private const double ArrowHalfWidth = 6;
+
+ /// Where a sensor's connecting line starts: just clear of the 30-thick ballast stroke.
+ private const double TrackEdge = 18;
+
+ [ObservableProperty] private Guid id = Guid.CreateVersion7();
[ObservableProperty] private string name = "";
[ObservableProperty] private SegmentType type = SegmentType.Straight;
[ObservableProperty] private int maxSpeed = 70;
@@ -14,40 +27,113 @@ public partial class TrackSegment : ObservableObject
[ObservableProperty] private SensorActionType action = SensorActionType.Notify;
[ObservableProperty] private int slowTarget = 30;
+ // Strand/branch graph — see Domain's TrackSegment for the full field-by-field rationale.
+ [ObservableProperty] private Guid strandId = Guid.Empty;
+ [ObservableProperty] private int order;
+ [ObservableProperty] private CurveDirection? curve;
+ [ObservableProperty] private Guid? branchStrandId;
+ [ObservableProperty] private CurveDirection? branchDirection;
+ [ObservableProperty] private SwitchRoute route = SwitchRoute.Main;
+ [ObservableProperty] private Guid? branchLinkSegmentId;
+
/// Static SVG-style path data for the track centerline, in the shared 900x600 canvas viewBox.
- public string PathData { get; set; } = "";
+ public string PathData { get; private set; } = "";
/// Wider, untrimmed path data used for pointer hit-testing and the selection glow.
- public string HitPathData { get; set; } = "";
-
- /// Segment midpoint, used as the direction-arrow's translation anchor.
- public double MidX { get; set; }
- public double MidY { get; set; }
-
- /// Top-left of a fixed 40x20 box centered on the speed-label position (Canvas.Left/Top friendly).
- public double LabelLeft { get; set; }
- public double LabelTop { get; set; }
-
- /// Top-left of the fixed 30x30 sensor-marker box (Canvas.Left/Top friendly).
- public double SensorLeft { get; set; }
- public double SensorTop { get; set; }
+ public string HitPathData { get; private set; } = "";
+
+ /// Segment midpoint — every marker on the piece (label, sensor, arrow) hangs off it.
+ public double MidX { get; private set; }
+ public double MidY { get; private set; }
+
+ /// Top-left of the speed label's box, centered on its position (Canvas.Left/Top friendly).
+ public double LabelLeft { get; private set; }
+ public double LabelTop { get; private set; }
+
+ /// Top-left of the sensor-marker box (Canvas.Left/Top friendly).
+ public double SensorLeft { get; private set; }
+ public double SensorTop { get; private set; }
+
+ /// The travel-direction arrow: a triangle at the segment midpoint, pointing along the
+ /// track (flipped for ), as ready-to-draw path data. It is
+ /// emitted rather than drawn once and rotated by a bound RenderTransform, because a
+ /// {Binding} inside a transform has no DataContext to inherit — transforms aren't in the
+ /// visual tree — so the angle silently never arrives.
+ public string ArrowPathData
+ {
+ get
+ {
+ var sign = Direction == TrackDirection.Reverse ? -1 : 1;
+ var (tanX, tanY) = (TanX * sign, TanY * sign);
+ var (tipX, tipY) = (MidX + (tanX * ArrowLength / 2), MidY + (tanY * ArrowLength / 2));
+ var (backX, backY) = (MidX - (tanX * ArrowLength / 2), MidY - (tanY * ArrowLength / 2));
+ var (leftX, leftY) = (backX + (tanY * ArrowHalfWidth), backY - (tanX * ArrowHalfWidth));
+ var (rightX, rightY) = (backX - (tanY * ArrowHalfWidth), backY + (tanX * ArrowHalfWidth));
+
+ return FormattableString.Invariant($"M {leftX:0.0} {leftY:0.0} L {tipX:0.0} {tipY:0.0} L {rightX:0.0} {rightY:0.0} Z");
+ }
+ }
- public double SensorLineX1 { get; set; }
- public double SensorLineY1 { get; set; }
- public double SensorLineX2 { get; set; }
- public double SensorLineY2 { get; set; }
+ public double SensorLineX1 { get; private set; }
+ public double SensorLineY1 { get; private set; }
+ public double SensorLineX2 { get; private set; }
+ public double SensorLineY2 { get; private set; }
/// Raw track-tangent direction at the segment midpoint (forward travel), used to orient the direction arrow.
- public double TanX { get; set; }
- public double TanY { get; set; }
+ public double TanX { get; private set; }
+ public double TanY { get; private set; }
+
+ /// Copies a freshly-computed result onto this segment and
+ /// derives the label/sensor marker positions from it — called for every segment after any
+ /// structural change to the plan (append/delete/reorder/…), since a change anywhere upstream in
+ /// a strand shifts every piece after it.
+ public void ApplyGeometry(SegmentGeometry geometry)
+ {
+ PathData = geometry.PathData;
+ HitPathData = geometry.HitPathData;
+ MidX = geometry.MidX;
+ MidY = geometry.MidY;
+ TanX = geometry.TanX;
+ TanY = geometry.TanY;
+ // Label and sensor sit on opposite sides of the track, both offset along the piece's outward
+ // normal, then pulled back by half their box so the offset lands on the box's center.
+ LabelLeft = MidX - (geometry.OutwardX * LabelOffset) - (LabelWidth / 2);
+ LabelTop = MidY - (geometry.OutwardY * LabelOffset) - (LabelHeight / 2);
+ SensorLeft = MidX + (geometry.OutwardX * SensorOffset) - (SensorSize / 2);
+ SensorTop = MidY + (geometry.OutwardY * SensorOffset) - (SensorSize / 2);
+ SensorLineX1 = MidX + (geometry.OutwardX * TrackEdge);
+ SensorLineY1 = MidY + (geometry.OutwardY * TrackEdge);
+ SensorLineX2 = MidX + (geometry.OutwardX * (SensorOffset - 2));
+ SensorLineY2 = MidY + (geometry.OutwardY * (SensorOffset - 2));
+
+ OnPropertyChanged(nameof(PathData));
+ OnPropertyChanged(nameof(HitPathData));
+ OnPropertyChanged(nameof(MidX));
+ OnPropertyChanged(nameof(MidY));
+ OnPropertyChanged(nameof(TanX));
+ OnPropertyChanged(nameof(TanY));
+ OnPropertyChanged(nameof(LabelLeft));
+ OnPropertyChanged(nameof(LabelTop));
+ OnPropertyChanged(nameof(SensorLeft));
+ OnPropertyChanged(nameof(SensorTop));
+ OnPropertyChanged(nameof(ArrowPathData));
+ OnPropertyChanged(nameof(SensorLineX1));
+ OnPropertyChanged(nameof(SensorLineY1));
+ OnPropertyChanged(nameof(SensorLineX2));
+ OnPropertyChanged(nameof(SensorLineY2));
+ }
+
+ public TrackGeometryInput GeometryInput => new(Id, Type, Curve, StrandId, Order, BranchStrandId, BranchDirection);
- public double ArrowRotationDeg => Math.Atan2(TanY, TanX) * 180 / Math.PI + (Direction == TrackDirection.Reverse ? 180 : 0);
+ public bool IsWeiche => Type == SegmentType.Switch;
public string TypeLabel => Type switch
{
SegmentType.Straight => "Gerade",
- SegmentType.Curve => "Kurve",
- _ => "Bahnhof",
+ SegmentType.Curve => Curve == CurveDirection.Left ? "Kurve links" : "Kurve rechts",
+ SegmentType.Switch => "Weiche",
+ SegmentType.Station => "Bahnhof",
+ _ => "Segment",
};
public string SpeedColor => MaxSpeed switch
@@ -106,5 +192,13 @@ partial void OnSensorChanged(SensorType value)
partial void OnActionChanged(SensorActionType value) => OnPropertyChanged(nameof(ShowSlowTarget));
- partial void OnDirectionChanged(TrackDirection value) => OnPropertyChanged(nameof(ArrowRotationDeg));
+ partial void OnDirectionChanged(TrackDirection value) => OnPropertyChanged(nameof(ArrowPathData));
+
+ partial void OnTypeChanged(SegmentType value)
+ {
+ OnPropertyChanged(nameof(TypeLabel));
+ OnPropertyChanged(nameof(IsWeiche));
+ }
+
+ partial void OnCurveChanged(CurveDirection? value) => OnPropertyChanged(nameof(TypeLabel));
}
diff --git a/Source/Trackify/Presentation/Behaviors/ColumnSplitterBehavior.cs b/Source/Trackify/Presentation/Behaviors/ColumnSplitterBehavior.cs
new file mode 100644
index 0000000..3cfecb5
--- /dev/null
+++ b/Source/Trackify/Presentation/Behaviors/ColumnSplitterBehavior.cs
@@ -0,0 +1,100 @@
+using System.Runtime.CompilerServices;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+
+namespace Trackify.Presentation.Behaviors;
+
+///
+/// Attached behavior: turns a thin element (a hairline divider) into a drag handle that resizes a
+/// — a hand-rolled GridSplitter, since none ships with
+/// Uno.Toolkit/WinUI in this project (adding one means a new third-party package). Usage:
+/// local:ColumnSplitterBehavior.Target="{Binding ElementName=ListColumn}", optionally with
+/// local:ColumnSplitterBehavior.MinWidth/MaxWidth. Set
+/// local:ColumnSplitterBehavior.Invert="True" when Target is the column to the
+/// splitter's *right* (dragging right then shrinks it instead of growing it) — the default assumes
+/// Target is the column on the splitter's left.
+///
+public static class ColumnSplitterBehavior
+{
+ private const double DefaultMinWidth = 160;
+ private const double DefaultMaxWidth = 640;
+
+ private static readonly ConditionalWeakTable States = [];
+
+ public static readonly DependencyProperty TargetProperty = DependencyProperty.RegisterAttached(
+ "Target", typeof(ColumnDefinition), typeof(ColumnSplitterBehavior), new PropertyMetadata(null, OnTargetChanged));
+
+ public static readonly DependencyProperty MinWidthProperty = DependencyProperty.RegisterAttached(
+ "MinWidth", typeof(double), typeof(ColumnSplitterBehavior), new PropertyMetadata(DefaultMinWidth));
+
+ public static readonly DependencyProperty MaxWidthProperty = DependencyProperty.RegisterAttached(
+ "MaxWidth", typeof(double), typeof(ColumnSplitterBehavior), new PropertyMetadata(DefaultMaxWidth));
+
+ public static readonly DependencyProperty InvertProperty = DependencyProperty.RegisterAttached(
+ "Invert", typeof(bool), typeof(ColumnSplitterBehavior), new PropertyMetadata(false));
+
+ public static ColumnDefinition? GetTarget(DependencyObject element) => (ColumnDefinition?)element.GetValue(TargetProperty);
+
+ public static void SetTarget(DependencyObject element, ColumnDefinition? value) => element.SetValue(TargetProperty, value);
+
+ public static double GetMinWidth(DependencyObject element) => (double)element.GetValue(MinWidthProperty);
+
+ public static void SetMinWidth(DependencyObject element, double value) => element.SetValue(MinWidthProperty, value);
+
+ public static double GetMaxWidth(DependencyObject element) => (double)element.GetValue(MaxWidthProperty);
+
+ public static void SetMaxWidth(DependencyObject element, double value) => element.SetValue(MaxWidthProperty, value);
+
+ public static bool GetInvert(DependencyObject element) => (bool)element.GetValue(InvertProperty);
+
+ public static void SetInvert(DependencyObject element, bool value) => element.SetValue(InvertProperty, value);
+
+ private static void OnTargetChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
+ {
+ if (element is not FrameworkElement fe)
+ return;
+
+ fe.PointerPressed -= OnPointerPressed;
+ fe.PointerMoved -= OnPointerMoved;
+ fe.PointerReleased -= OnPointerReleased;
+
+ if (e.NewValue is ColumnDefinition)
+ {
+ fe.PointerPressed += OnPointerPressed;
+ fe.PointerMoved += OnPointerMoved;
+ fe.PointerReleased += OnPointerReleased;
+ }
+ }
+
+ private static void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe || GetTarget(fe) is not { } column)
+ return;
+
+ States.AddOrUpdate(fe, new DragState(e.GetCurrentPoint(fe).Position.X, column.ActualWidth));
+ fe.CapturePointer(e.Pointer);
+ }
+
+ private static void OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe || GetTarget(fe) is not { } column || !States.TryGetValue(fe, out var state))
+ return;
+
+ var delta = e.GetCurrentPoint(fe).Position.X - state.StartX;
+ if (GetInvert(fe)) delta = -delta;
+ var width = Math.Clamp(state.StartWidth + delta, GetMinWidth(fe), GetMaxWidth(fe));
+ column.Width = new GridLength(width);
+ }
+
+ private static void OnPointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe)
+ return;
+
+ States.Remove(fe);
+ fe.ReleasePointerCapture(e.Pointer);
+ }
+
+ private sealed record DragState(double StartX, double StartWidth);
+}
diff --git a/Source/Trackify/Presentation/Behaviors/RowSplitterBehavior.cs b/Source/Trackify/Presentation/Behaviors/RowSplitterBehavior.cs
new file mode 100644
index 0000000..5dc2fe9
--- /dev/null
+++ b/Source/Trackify/Presentation/Behaviors/RowSplitterBehavior.cs
@@ -0,0 +1,99 @@
+using System.Runtime.CompilerServices;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+
+namespace Trackify.Presentation.Behaviors;
+
+///
+/// Attached behavior: turns a thin element (a hairline divider) into a drag handle that resizes a
+/// — the vertical counterpart of (see
+/// its remarks for why this is hand-rolled instead of a package GridSplitter). Usage:
+/// local:RowSplitterBehavior.Target="{Binding ElementName=SomeRow}", optionally with
+/// local:RowSplitterBehavior.MinHeight/MaxHeight. Target defaults to the row
+/// *above* the splitter (dragging down grows it, dragging up shrinks it) — set
+/// local:RowSplitterBehavior.Invert="True" when Target is the row *below* instead.
+///
+public static class RowSplitterBehavior
+{
+ private const double DefaultMinHeight = 90;
+ private const double DefaultMaxHeight = 800;
+
+ private static readonly ConditionalWeakTable States = [];
+
+ public static readonly DependencyProperty TargetProperty = DependencyProperty.RegisterAttached(
+ "Target", typeof(RowDefinition), typeof(RowSplitterBehavior), new PropertyMetadata(null, OnTargetChanged));
+
+ public static readonly DependencyProperty MinHeightProperty = DependencyProperty.RegisterAttached(
+ "MinHeight", typeof(double), typeof(RowSplitterBehavior), new PropertyMetadata(DefaultMinHeight));
+
+ public static readonly DependencyProperty MaxHeightProperty = DependencyProperty.RegisterAttached(
+ "MaxHeight", typeof(double), typeof(RowSplitterBehavior), new PropertyMetadata(DefaultMaxHeight));
+
+ public static readonly DependencyProperty InvertProperty = DependencyProperty.RegisterAttached(
+ "Invert", typeof(bool), typeof(RowSplitterBehavior), new PropertyMetadata(false));
+
+ public static RowDefinition? GetTarget(DependencyObject element) => (RowDefinition?)element.GetValue(TargetProperty);
+
+ public static void SetTarget(DependencyObject element, RowDefinition? value) => element.SetValue(TargetProperty, value);
+
+ public static double GetMinHeight(DependencyObject element) => (double)element.GetValue(MinHeightProperty);
+
+ public static void SetMinHeight(DependencyObject element, double value) => element.SetValue(MinHeightProperty, value);
+
+ public static double GetMaxHeight(DependencyObject element) => (double)element.GetValue(MaxHeightProperty);
+
+ public static void SetMaxHeight(DependencyObject element, double value) => element.SetValue(MaxHeightProperty, value);
+
+ public static bool GetInvert(DependencyObject element) => (bool)element.GetValue(InvertProperty);
+
+ public static void SetInvert(DependencyObject element, bool value) => element.SetValue(InvertProperty, value);
+
+ private static void OnTargetChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
+ {
+ if (element is not FrameworkElement fe)
+ return;
+
+ fe.PointerPressed -= OnPointerPressed;
+ fe.PointerMoved -= OnPointerMoved;
+ fe.PointerReleased -= OnPointerReleased;
+
+ if (e.NewValue is RowDefinition)
+ {
+ fe.PointerPressed += OnPointerPressed;
+ fe.PointerMoved += OnPointerMoved;
+ fe.PointerReleased += OnPointerReleased;
+ }
+ }
+
+ private static void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe || GetTarget(fe) is not { } row)
+ return;
+
+ States.AddOrUpdate(fe, new DragState(e.GetCurrentPoint(fe).Position.Y, row.ActualHeight));
+ fe.CapturePointer(e.Pointer);
+ }
+
+ private static void OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe || GetTarget(fe) is not { } row || !States.TryGetValue(fe, out var state))
+ return;
+
+ var delta = e.GetCurrentPoint(fe).Position.Y - state.StartY;
+ if (GetInvert(fe)) delta = -delta;
+ var height = Math.Clamp(state.StartHeight + delta, GetMinHeight(fe), GetMaxHeight(fe));
+ row.Height = new GridLength(height);
+ }
+
+ private static void OnPointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement fe)
+ return;
+
+ States.Remove(fe);
+ fe.ReleasePointerCapture(e.Pointer);
+ }
+
+ private sealed record DragState(double StartY, double StartHeight);
+}
diff --git a/Source/Trackify/Presentation/Components/AddHubDialog.xaml b/Source/Trackify/Presentation/Components/AddHubDialog.xaml
index 5c1cb63..b0e5458 100644
--- a/Source/Trackify/Presentation/Components/AddHubDialog.xaml
+++ b/Source/Trackify/Presentation/Components/AddHubDialog.xaml
@@ -1,6 +1,7 @@
-
+
+
-
+
-
+
-
+
@@ -36,12 +37,13 @@
-
+ Style="{StaticResource FabButtonStyle}" Height="44" Command="{Binding ConfirmAddHubCommand}" />
+
diff --git a/Source/Trackify/Presentation/Components/SegmentInspector.xaml b/Source/Trackify/Presentation/Components/SegmentInspector.xaml
index d10e7bc..efcebc6 100644
--- a/Source/Trackify/Presentation/Components/SegmentInspector.xaml
+++ b/Source/Trackify/Presentation/Components/SegmentInspector.xaml
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Trackify.Presentation.Widgets"
+ xmlns:behaviors="using:Trackify.Presentation.Behaviors"
xmlns:vm="using:Trackify.Presentation.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -13,19 +14,33 @@
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
+
+
+
@@ -53,11 +68,11 @@
-
-
@@ -89,8 +104,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -108,15 +204,15 @@
-
-
-
@@ -149,8 +245,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Trackify/Presentation/Components/StrandList.xaml.cs b/Source/Trackify/Presentation/Components/StrandList.xaml.cs
new file mode 100644
index 0000000..de92b3b
--- /dev/null
+++ b/Source/Trackify/Presentation/Components/StrandList.xaml.cs
@@ -0,0 +1,9 @@
+using Microsoft.UI.Xaml.Controls;
+
+namespace Trackify.Presentation.Components;
+
+/// Strand-grouped segment list (numbered rows, reorder/duplicate/delete). Binds to the hosting SecondViewModel.
+public sealed partial class StrandList : UserControl
+{
+ public StrandList() => this.InitializeComponent();
+}
diff --git a/Source/Trackify/Presentation/Components/TrackCanvas.xaml b/Source/Trackify/Presentation/Components/TrackCanvas.xaml
index 3402f81..1463b31 100644
--- a/Source/Trackify/Presentation/Components/TrackCanvas.xaml
+++ b/Source/Trackify/Presentation/Components/TrackCanvas.xaml
@@ -3,171 +3,281 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Trackify.Presentation.Behaviors"
+ xmlns:widgets="using:Trackify.Presentation.Widgets"
xmlns:vm="using:Trackify.Presentation.ViewModels"
+ xmlns:app="using:Trackify.Application.Catalog"
xmlns:models="using:Trackify.Models.Trains"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SecondViewModel}">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/Source/Trackify/Presentation/Components/TrackCanvas.xaml.cs b/Source/Trackify/Presentation/Components/TrackCanvas.xaml.cs
index f99b5ba..e7659ae 100644
--- a/Source/Trackify/Presentation/Components/TrackCanvas.xaml.cs
+++ b/Source/Trackify/Presentation/Components/TrackCanvas.xaml.cs
@@ -2,7 +2,7 @@
namespace Trackify.Presentation.Components;
-/// 2D track layout (segments + sensors). Binds to the hosting SecondViewModel.
+/// 2D track layout (segments + sensors) with pan/zoom. Binds to the hosting SecondViewModel.
public sealed partial class TrackCanvas : UserControl
{
public TrackCanvas() => this.InitializeComponent();
diff --git a/Source/Trackify/Presentation/Components/TrainEditor.xaml b/Source/Trackify/Presentation/Components/TrainEditor.xaml
index 03d69c1..cd31251 100644
--- a/Source/Trackify/Presentation/Components/TrainEditor.xaml
+++ b/Source/Trackify/Presentation/Components/TrainEditor.xaml
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Trackify.Presentation.Widgets"
+ xmlns:behaviors="using:Trackify.Presentation.Behaviors"
xmlns:vm="using:Trackify.Presentation.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -14,17 +15,63 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
@@ -73,6 +120,8 @@
+
+
@@ -127,18 +176,28 @@
+
+
+
+
+
+
+
+
+ BorderThickness="1" Padding="14" VerticalAlignment="Top">
-
-
+
@@ -146,13 +205,13 @@
-
+
-
+
@@ -180,8 +239,18 @@
+
+
+
+
+
+
+
+
@@ -254,8 +323,18 @@
+
+
+
+
+
+
+
+
@@ -267,13 +346,13 @@
-
@@ -282,8 +361,10 @@
+
+
-
+
@@ -58,29 +59,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Source/Trackify/Presentation/Pages/MainPage.xaml b/Source/Trackify/Presentation/Pages/MainPage.xaml
index 173c3ca..f670b50 100644
--- a/Source/Trackify/Presentation/Pages/MainPage.xaml
+++ b/Source/Trackify/Presentation/Pages/MainPage.xaml
@@ -2,6 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Trackify.Presentation.Components"
+ xmlns:widgets="using:Trackify.Presentation.Widgets"
+ xmlns:behaviors="using:Trackify.Presentation.Behaviors"
xmlns:vm="using:Trackify.Presentation.ViewModels"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -40,7 +42,8 @@
-
+
@@ -49,7 +52,7 @@
-
+
@@ -79,15 +82,23 @@
-
+
+
-
+
+
+
+
@@ -97,27 +108,28 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/Source/Trackify/Presentation/Pages/MainPage.xaml.cs b/Source/Trackify/Presentation/Pages/MainPage.xaml.cs
index 5eecca7..5b004ee 100644
--- a/Source/Trackify/Presentation/Pages/MainPage.xaml.cs
+++ b/Source/Trackify/Presentation/Pages/MainPage.xaml.cs
@@ -48,26 +48,32 @@ private void ApplyResponsiveLayout()
if (isWide)
{
- // Two columns side by side: list rail + editor.
- ListColumn.Width = new GridLength(346);
+ // Two columns side by side: list rail (drag-resizable via SplitterHandle) + editor.
+ if (ListColumn.Width.Value == 0) ListColumn.Width = new GridLength(346);
+ SplitterColumn.Width = new GridLength(6);
EditorColumn.Width = new GridLength(1, GridUnitType.Star);
ListPanel.Visibility = Visibility.Visible;
+ SplitterHandle.Visibility = Visibility.Visible;
EditorHost.Visibility = Visibility.Visible;
}
else if (hasSelection)
{
// Narrow + a train selected: show the editor full width.
ListColumn.Width = new GridLength(0);
+ SplitterColumn.Width = new GridLength(0);
EditorColumn.Width = new GridLength(1, GridUnitType.Star);
ListPanel.Visibility = Visibility.Collapsed;
+ SplitterHandle.Visibility = Visibility.Collapsed;
EditorHost.Visibility = Visibility.Visible;
}
else
{
// Narrow + nothing selected: show the list full width (the "home" pane).
ListColumn.Width = new GridLength(1, GridUnitType.Star);
+ SplitterColumn.Width = new GridLength(0);
EditorColumn.Width = new GridLength(0);
ListPanel.Visibility = Visibility.Visible;
+ SplitterHandle.Visibility = Visibility.Collapsed;
EditorHost.Visibility = Visibility.Collapsed;
}
diff --git a/Source/Trackify/Presentation/Pages/SecondPage.xaml b/Source/Trackify/Presentation/Pages/SecondPage.xaml
index dd4248d..5861efe 100644
--- a/Source/Trackify/Presentation/Pages/SecondPage.xaml
+++ b/Source/Trackify/Presentation/Pages/SecondPage.xaml
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Trackify.Presentation.Components"
xmlns:vm="using:Trackify.Presentation.ViewModels"
+ xmlns:behaviors="using:Trackify.Presentation.Behaviors"
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -37,7 +38,8 @@
-
+
@@ -48,7 +50,7 @@
-
+
@@ -61,19 +63,44 @@
-
-
-
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
diff --git a/Source/Trackify/Presentation/Pages/SecondPage.xaml.cs b/Source/Trackify/Presentation/Pages/SecondPage.xaml.cs
index 71b4581..6c9d26c 100644
--- a/Source/Trackify/Presentation/Pages/SecondPage.xaml.cs
+++ b/Source/Trackify/Presentation/Pages/SecondPage.xaml.cs
@@ -1,6 +1,99 @@
+using System.ComponentModel;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
namespace Trackify.Presentation.Pages;
+///
+/// Responsive layout for the Streckenplaner, same reasoning/shape as : reacts
+/// to both width and a selection change, which VisualStateManager/AdaptiveTrigger can't express
+/// declaratively. Wide: canvas | strand list | inspector as three columns. Narrow: canvas + strand
+/// list stacked, inspector as a bottom sheet shown only while a segment is selected.
+///
public sealed partial class SecondPage : Page
{
- public SecondPage() => this.InitializeComponent();
+ private const double WideThreshold = 720;
+
+ private SecondViewModel? _viewModel;
+
+ public SecondPage()
+ {
+ this.InitializeComponent();
+
+ SizeChanged += (_, _) => ApplyResponsiveLayout();
+ Loaded += (_, _) => ApplyResponsiveLayout();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
+ {
+ if (_viewModel is not null)
+ _viewModel.PropertyChanged -= OnViewModelPropertyChanged;
+
+ _viewModel = args.NewValue as SecondViewModel;
+
+ if (_viewModel is not null)
+ _viewModel.PropertyChanged += OnViewModelPropertyChanged;
+
+ ApplyResponsiveLayout();
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SecondViewModel.SelectedSegment))
+ ApplyResponsiveLayout();
+ }
+
+ private void ApplyResponsiveLayout()
+ {
+ var isWide = ActualWidth >= WideThreshold;
+ var hasSelection = _viewModel?.SelectedSegment is not null;
+
+ if (isWide)
+ {
+ // Three columns side by side: canvas + toolbar, strand-list sidebar (drag-resizable via
+ // Splitter1Handle), inspector (drag-resizable via Splitter2Handle).
+ if (ListColumn.Width.Value == 0) ListColumn.Width = new GridLength(236);
+ if (InspectorColumn.Width.Value == 0) InspectorColumn.Width = new GridLength(318);
+ Splitter1Column.Width = new GridLength(6);
+ Splitter2Column.Width = new GridLength(6);
+ CanvasRow.Height = new GridLength(1, GridUnitType.Star);
+ ListRow.Height = new GridLength(0);
+
+ Grid.SetColumn(ListHost, 2);
+ Grid.SetRow(ListHost, 0);
+ Grid.SetColumn(InspectorHost, 4);
+ Grid.SetRow(InspectorHost, 0);
+ Grid.SetRowSpan(InspectorHost, 1);
+
+ Splitter1Handle.Visibility = Visibility.Visible;
+ Splitter2Handle.Visibility = Visibility.Visible;
+ InspectorHost.VerticalAlignment = VerticalAlignment.Stretch;
+ InspectorHost.MaxHeight = double.PositiveInfinity;
+ InspectorHost.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ // Stacked: canvas + toolbar on top, strand list below; inspector docks as a bottom sheet
+ // over both, only while a segment is selected.
+ ListColumn.Width = new GridLength(0);
+ InspectorColumn.Width = new GridLength(0);
+ Splitter1Column.Width = new GridLength(0);
+ Splitter2Column.Width = new GridLength(0);
+ CanvasRow.Height = new GridLength(3, GridUnitType.Star);
+ ListRow.Height = new GridLength(2, GridUnitType.Star);
+
+ Grid.SetColumn(ListHost, 0);
+ Grid.SetRow(ListHost, 1);
+ Grid.SetColumn(InspectorHost, 0);
+ Grid.SetRow(InspectorHost, 0);
+ Grid.SetRowSpan(InspectorHost, 2);
+
+ Splitter1Handle.Visibility = Visibility.Collapsed;
+ Splitter2Handle.Visibility = Visibility.Collapsed;
+ InspectorHost.VerticalAlignment = VerticalAlignment.Bottom;
+ InspectorHost.MaxHeight = 520;
+ InspectorHost.Visibility = hasSelection ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
}
diff --git a/Source/Trackify/Presentation/ViewModels/MainViewModel.cs b/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
index 9fa35be..424f598 100644
--- a/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
+++ b/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
@@ -20,7 +20,6 @@ public partial class MainViewModel : ObservableObject
private int _sequence = 1;
[ObservableProperty] private string search = "";
- [ObservableProperty] private TrainFilterType filter = TrainFilterType.All;
[ObservableProperty] private Train? selectedTrain;
[ObservableProperty] private int activeCount;
[ObservableProperty] private int totalCount;
@@ -45,8 +44,6 @@ public partial class MainViewModel : ObservableObject
public IRelayCommand DeleteTrainCommand { get; }
- public IRelayCommand SetFilterCommand { get; }
-
public IRelayCommand SetColorCommand { get; }
public IAsyncRelayCommand GoToStreckenplanerCommand { get; }
@@ -85,7 +82,6 @@ public MainViewModel(INavigator navigator, ILegoService lego, ConnectionState co
AddTrainCommand = new RelayCommand(AddTrain);
DuplicateTrainCommand = new RelayCommand(DuplicateTrain, () => SelectedTrain is not null);
DeleteTrainCommand = new RelayCommand(DeleteTrain, () => SelectedTrain is not null);
- SetFilterCommand = new RelayCommand(name => Filter = Enum.Parse(name!));
SetColorCommand = new RelayCommand(SetSelectedTrainColor);
GoToStreckenplanerCommand = new AsyncRelayCommand(GoToStreckenplaner);
ConnectCommand = new AsyncRelayCommand(ConnectSelectedTrainAsync, () => SelectedTrain is { IsHardwareConnected: false });
@@ -102,7 +98,7 @@ public MainViewModel(INavigator navigator, ILegoService lego, ConnectionState co
ColorSwatches = [.. LegoinoCatalog.Colors.Select(c => new ColorSwatchItemViewModel { Value = c.Value, Name = c.Name, Hex = c.Hex })];
Trains.CollectionChanged += TrainsOnCollectionChanged;
- ApplyFilter();
+ RefreshFilteredTrains();
}
private void AddTrain()
@@ -124,7 +120,7 @@ private void DuplicateTrain()
var copy = SelectedTrain.Clone($"trn-{_sequence++}");
Trains.Insert(Trains.IndexOf(SelectedTrain) + 1, copy);
SelectedTrain = copy;
- ApplyFilter();
+ RefreshFilteredTrains();
}
private void DeleteTrain()
@@ -133,7 +129,7 @@ private void DeleteTrain()
Trains.Remove(SelectedTrain);
SelectedTrain = Trains.FirstOrDefault();
- ApplyFilter();
+ RefreshFilteredTrains();
}
private void SetSelectedTrainColor(LedColorType? color)
@@ -142,13 +138,12 @@ private void SetSelectedTrainColor(LedColorType? color)
SelectedTrain.Color = value;
}
- // Clears search/filter so a just-added train is visible, then selects it.
+ // Clears the search so a just-added train is visible, then selects it.
private void SelectAfresh(Train train)
{
- Filter = TrainFilterType.All;
Search = "";
SelectedTrain = train;
- ApplyFilter();
+ RefreshFilteredTrains();
}
// No token: a user-initiated page change runs to completion.
@@ -188,9 +183,7 @@ private async Task ApplyConnectionAsync()
}
}
- partial void OnSearchChanged(string value) => ApplyFilter();
-
- partial void OnFilterChanged(TrainFilterType value) => ApplyFilter();
+ partial void OnSearchChanged(string value) => RefreshFilteredTrains();
partial void OnSelectedTrainChanged(Train? oldValue, Train? newValue)
{
@@ -205,7 +198,7 @@ partial void OnSelectedTrainChanged(Train? oldValue, Train? newValue)
private void SelectedTrainOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName is nameof(Train.Name) or nameof(Train.IsActive)) ApplyFilter();
+ if (e.PropertyName is nameof(Train.Name) or nameof(Train.IsActive)) RefreshFilteredTrains();
if (e.PropertyName is nameof(Train.Color)) UpdateColorSwatchSelection();
if (sender is Train train)
@@ -236,29 +229,20 @@ private void UpdateColorSwatchSelection()
swatch.IsSelected = SelectedTrain is not null && swatch.Value == SelectedTrain.Color;
}
- private void ApplyFilter()
+ private void RefreshFilteredTrains()
{
FilteredTrains.Clear();
- foreach (var train in Trains.Where(MatchesFilterAndSearch))
+ foreach (var train in Trains.Where(MatchesSearch))
FilteredTrains.Add(train);
RefreshCounts();
}
- private bool MatchesFilterAndSearch(Train train)
+ private bool MatchesSearch(Train train)
{
- var matchesFilter = Filter switch
- {
- TrainFilterType.Active => train.IsActive,
- TrainFilterType.Inactive => !train.IsActive,
- _ => true,
- };
-
var query = Search.Trim();
- var matchesSearch = query.Length == 0
+ return query.Length == 0
|| train.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
|| train.Hub.ToString().Contains(query, StringComparison.OrdinalIgnoreCase);
-
- return matchesFilter && matchesSearch;
}
}
diff --git a/Source/Trackify/Presentation/ViewModels/SecondViewModel.cs b/Source/Trackify/Presentation/ViewModels/SecondViewModel.cs
index b9d88f4..a33ab22 100644
--- a/Source/Trackify/Presentation/ViewModels/SecondViewModel.cs
+++ b/Source/Trackify/Presentation/ViewModels/SecondViewModel.cs
@@ -8,15 +8,37 @@ namespace Trackify.Presentation.ViewModels;
public partial class SecondViewModel : ObservableObject
{
private readonly INavigator _navigator;
+ private readonly ITrackSegmentRepository _repository;
+ private readonly ITrackPlanService _trackPlanService;
[ObservableProperty] private TrackSegment? selectedSegment;
[ObservableProperty] private int segmentCount;
[ObservableProperty] private int sensorCount;
[ObservableProperty] private int averageSpeed;
+ [ObservableProperty] private string trackBedPathData = "";
+ [ObservableProperty] private Guid activeStrandId = Guid.Empty;
+ [ObservableProperty] private bool dlgOpen;
+ [ObservableProperty] private bool isSaving;
+ [ObservableProperty] private double zoomFactor = 1;
+
+ private const double MinZoom = 0.4;
+ private const double MaxZoom = 3;
+ private const double ZoomStep = 0.2;
+ private const double CanvasBaseWidth = 900;
+ private const double CanvasBaseHeight = 600;
+
+ /// The canvas's displayed size at the current zoom — bound onto a Viewbox wrapping the
+ /// fixed 900x600-coordinate canvas, so zoom is a plain bound number, not an imperative
+ /// ScrollViewer API call (which isn't reliably supported across every Uno target).
+ public double CanvasWidth => CanvasBaseWidth * ZoomFactor;
+
+ public double CanvasHeight => CanvasBaseHeight * ZoomFactor;
public ObservableCollection Segments { get; } = [];
- public string TrackBedPathData { get; }
+ public ObservableCollection StrandGroups { get; } = [];
+
+ public IReadOnlyList TrackParts => LegoinoCatalog.TrackParts;
public IReadOnlyList SpeedFunctionOptions { get; } =
[.. LegoinoCatalog.SpeedFunctions.Where(f => f.Value != SpeedFunctionType.Custom)];
@@ -35,6 +57,23 @@ public partial class SecondViewModel : ObservableObject
new("#16A34A", "schnell"),
];
+ public string ActiveStrandName => StrandGroups.FirstOrDefault(g => g.StrandId == ActiveStrandId)?.Name ?? "Hauptstrecke";
+
+ /// The Weiche a "Weiche einfügen" dialog is currently being opened for — the strand the
+ /// new switch is appended to (usually ).
+ public Guid PendingSwitchStrandId { get; private set; }
+
+ /// The selected Weiche's branch strand, if any (drives the "Im Zweig bauen" section).
+ public string SelectedBranchName => Branch(SelectedSegment)?.Name ?? "";
+
+ public string SelectedBranchCount => Branch(SelectedSegment) is { } branch ? $"{branch.Count} Segmente" : "";
+
+ /// Candidate segments a branch's open end could be logically linked to ("Zweig-Ende
+ /// anschließen an") — stored as metadata for later routing use; this pass doesn't bend the
+ /// branch's drawn geometry to visually meet the target.
+ public IReadOnlyList BranchLinkOptions =>
+ SelectedSegment is null ? [] : [.. Segments.Where(s => s.StrandId != SelectedSegment.BranchStrandId)];
+
public IRelayCommand SelectSegmentCommand { get; }
public IRelayCommand SetDirectionCommand { get; }
@@ -43,70 +82,300 @@ public partial class SecondViewModel : ObservableObject
public IAsyncRelayCommand GoBackCommand { get; }
- public SecondViewModel(INavigator navigator)
+ public SecondViewModel(INavigator navigator, ITrackSegmentRepository repository, ITrackPlanService trackPlanService)
{
_navigator = navigator;
+ _repository = repository;
+ _trackPlanService = trackPlanService;
SelectSegmentCommand = new RelayCommand(t => SelectedSegment = t);
SetDirectionCommand = new RelayCommand(v => { if (SelectedSegment is not null) SelectedSegment.Direction = Enum.Parse(v!); });
SetSensorCommand = new RelayCommand(v => { if (SelectedSegment is not null) SelectedSegment.Sensor = Enum.Parse(v!); });
GoBackCommand = new AsyncRelayCommand(async () => await _navigator.NavigateBackAsync(this));
- TrackBedPathData = TrackGeometry.BuildTrackBed();
+ _ = LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ var saved = await _repository.GetAllAsync();
+ var dtos = saved.Count > 0 ? [.. saved.Select(TrackSegmentMapping.ToDto)] : _trackPlanService.ApplyTemplate(TemplateType.Oval);
+
+ ApplyPlan(dtos);
+ SelectedSegment = Segments.OrderBy(s => s.StrandId == ActiveStrandId ? 0 : 1).ThenBy(s => s.Order).FirstOrDefault();
+ }
+
+ [RelayCommand]
+ private void AddPart(TrackPartOption part)
+ {
+ if (part.Type == SegmentType.Switch)
+ {
+ PendingSwitchStrandId = ActiveStrandId;
+ DlgOpen = true;
+ return;
+ }
+
+ ApplyPlan(_trackPlanService.AppendPart(CurrentDtos(), ActiveStrandId, part.Type, part.Curve));
+ }
+
+ [RelayCommand] private void ZoomIn() => ZoomFactor = Math.Min(MaxZoom, ZoomFactor + ZoomStep);
+ [RelayCommand] private void ZoomOut() => ZoomFactor = Math.Max(MinZoom, ZoomFactor - ZoomStep);
+ [RelayCommand] private void ZoomFit() => ZoomFactor = 1;
+
+ [RelayCommand] private void DlgLeftStay() => InsertSwitch(CurveDirection.Left, goToBranch: false);
+ [RelayCommand] private void DlgRightStay() => InsertSwitch(CurveDirection.Right, goToBranch: false);
+ [RelayCommand] private void DlgLeftGo() => InsertSwitch(CurveDirection.Left, goToBranch: true);
+ [RelayCommand] private void DlgRightGo() => InsertSwitch(CurveDirection.Right, goToBranch: true);
+ [RelayCommand] private void DlgCancel() => DlgOpen = false;
+
+ private void InsertSwitch(CurveDirection direction, bool goToBranch)
+ {
+ var result = _trackPlanService.InsertSwitch(CurrentDtos(), PendingSwitchStrandId, direction);
+ ApplyPlan(result.Segments);
+ DlgOpen = false;
+ if (goToBranch) ActiveStrandId = result.BranchStrandId;
+ }
+
+ [RelayCommand] private void TemplateOval() => ApplyTemplate(TemplateType.Oval);
+ [RelayCommand] private void TemplateAcht() => ApplyTemplate(TemplateType.Acht);
+ [RelayCommand] private void TemplatePunktZuPunkt() => ApplyTemplate(TemplateType.PunktZuPunkt);
+
+ [RelayCommand]
+ private void TemplateClear()
+ {
+ ActiveStrandId = Guid.Empty;
+ ApplyPlan(_trackPlanService.Clear());
+ SelectedSegment = null;
+ }
+
+ private void ApplyTemplate(TemplateType template)
+ {
+ ActiveStrandId = Guid.Empty;
+ ApplyPlan(_trackPlanService.ApplyTemplate(template));
+ SelectedSegment = Segments.OrderBy(s => s.Order).FirstOrDefault();
+ }
+
+ [RelayCommand]
+ private void SelectStrand(Guid strandId) => ActiveStrandId = strandId;
+
+ [RelayCommand]
+ private void GotoBranch()
+ {
+ if (SelectedSegment?.BranchStrandId is { } branchId) ActiveStrandId = branchId;
+ }
+
+ [RelayCommand]
+ private void SetBranchDirection(string direction)
+ {
+ if (SelectedSegment is null) return;
+ SelectedSegment.BranchDirection = Enum.Parse(direction);
+ }
+
+ [RelayCommand]
+ private void SetRoute(string route)
+ {
+ if (SelectedSegment is null) return;
+ SelectedSegment.Route = Enum.Parse(route);
+ }
+
+ [RelayCommand]
+ private void DuplicateSegment(TrackSegment? segment)
+ {
+ if ((segment ?? SelectedSegment) is not { } target) return;
+ ApplyPlan(_trackPlanService.Duplicate(CurrentDtos(), target.Id));
+ }
+
+ [RelayCommand] private void MoveUp(TrackSegment? segment) => Reorder(segment, up: true);
+
+ [RelayCommand] private void MoveDown(TrackSegment? segment) => Reorder(segment, up: false);
+
+ private void Reorder(TrackSegment? segment, bool up)
+ {
+ if ((segment ?? SelectedSegment) is not { } target) return;
+ ApplyPlan(_trackPlanService.Reorder(CurrentDtos(), target.Id, up));
+ }
+
+ [RelayCommand]
+ private void DeleteSegment(TrackSegment? segment)
+ {
+ if ((segment ?? SelectedSegment) is not { } target) return;
+ var wasSelected = SelectedSegment?.Id == target.Id;
- Seed();
- SelectedSegment = Segments.FirstOrDefault(s => s.Id == TrackGeometry.Seg2);
+ ApplyPlan(_trackPlanService.Delete(CurrentDtos(), target.Id));
+
+ if (wasSelected) SelectedSegment = null;
+ }
+
+ [RelayCommand]
+ private async Task SaveAsync()
+ {
+ IsSaving = true;
+ try
+ {
+ var existing = await _repository.GetAllAsync();
+ foreach (var stale in existing) await _repository.DeleteAsync(stale.Id);
+ await _repository.AddRangeAsync(Segments.Select(s => ToDto(s).ToEntity()));
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+
+ private List CurrentDtos() => [.. Segments.Select(ToDto)];
+
+ // Reconciles Segments with a freshly-computed plan (upsert by Id, in place, so SelectedSegment
+ // and any bound UI keep their identity), recomputes geometry for everything downstream of the
+ // change, and rebuilds the strand groups + stats.
+ private void ApplyPlan(IReadOnlyList dtos)
+ {
+ var byId = Segments.ToDictionary(s => s.Id);
+ var keepIds = new HashSet();
+
+ foreach (var dto in dtos)
+ {
+ keepIds.Add(dto.Id);
+ if (byId.TryGetValue(dto.Id, out var existing))
+ {
+ CopyInto(existing, dto);
+ }
+ else
+ {
+ var segment = ToModel(dto);
+ segment.PropertyChanged += SegmentOnPropertyChanged;
+ Segments.Add(segment);
+ }
+ }
+
+ foreach (var stale in Segments.Where(s => !keepIds.Contains(s.Id)).ToList())
+ {
+ stale.PropertyChanged -= SegmentOnPropertyChanged;
+ Segments.Remove(stale);
+ }
+
+ var layout = TrackGeometry.Build([.. Segments.Select(s => s.GeometryInput)]);
+ foreach (var segment in Segments)
+ {
+ if (layout.Segments.TryGetValue(segment.Id, out var geo)) segment.ApplyGeometry(geo);
+ }
+
+ TrackBedPathData = layout.BedPathData;
+
+ RebuildStrandGroups();
RefreshStats();
+
+ if (SelectedSegment is not null && !keepIds.Contains(SelectedSegment.Id)) SelectedSegment = null;
}
- private void Seed()
+ private void RebuildStrandGroups()
{
- var geometry = TrackGeometry.BuildSegments().ToDictionary(g => g.Id);
+ var branchNumber = new Dictionary();
+ var next = 1;
+ foreach (var owner in Segments.Where(s => s.Type == SegmentType.Switch && s.BranchStrandId is not null))
+ {
+ branchNumber[owner.BranchStrandId!.Value] = next++;
+ }
+
+ var strandIds = new List { Guid.Empty };
+ strandIds.AddRange(Segments.Select(s => s.StrandId).Distinct().Where(id => id != Guid.Empty)
+ .OrderBy(id => branchNumber.GetValueOrDefault(id, int.MaxValue)));
+
+ var existing = StrandGroups.ToDictionary(g => g.StrandId);
+ var keep = new HashSet();
- TrackSegment Create(
- string id, int maxSpeed,
- SpeedFunctionType accel = SpeedFunctionType.EaseOut, SpeedFunctionType brake = SpeedFunctionType.EaseIn,
- SensorType sensor = SensorType.None, SensorActionType action = SensorActionType.Notify, int slow = 30)
+ foreach (var strandId in strandIds)
{
- var g = geometry[id];
- var segment = new TrackSegment
+ keep.Add(strandId);
+ var name = strandId == Guid.Empty ? "Hauptstrecke" : $"Zweig {branchNumber.GetValueOrDefault(strandId, 0)}";
+
+ if (!existing.TryGetValue(strandId, out var group))
{
- Id = id,
- Name = TrackGeometry.Names[id],
- Type = TrackGeometry.Types[id],
- MaxSpeed = maxSpeed,
- AccelFn = accel,
- BrakeFn = brake,
- Sensor = sensor,
- Action = action,
- SlowTarget = slow,
- PathData = g.PathData,
- HitPathData = g.HitPathData,
- MidX = g.MidX,
- MidY = g.MidY,
- TanX = g.TanX,
- TanY = g.TanY,
- LabelLeft = g.MidX - (g.OutwardX * 40) - 20,
- LabelTop = g.MidY - (g.OutwardY * 40) + 5 - 10,
- SensorLeft = g.MidX + (g.OutwardX * 50) - 15,
- SensorTop = g.MidY + (g.OutwardY * 50) - 15,
- SensorLineX1 = g.MidX + (g.OutwardX * 18),
- SensorLineY1 = g.MidY + (g.OutwardY * 18),
- SensorLineX2 = g.MidX + (g.OutwardX * 48),
- SensorLineY2 = g.MidY + (g.OutwardY * 48),
- };
- segment.PropertyChanged += SegmentOnPropertyChanged;
- return segment;
+ group = new StrandGroup { StrandId = strandId };
+ StrandGroups.Add(group);
+ }
+ group.Name = name;
+ group.IsActive = strandId == ActiveStrandId;
+
+ group.Rows.Clear();
+ foreach (var row in Segments.Where(s => s.StrandId == strandId).OrderBy(s => s.Order)) group.Rows.Add(row);
+ group.Count = group.Rows.Count;
}
- Segments.Add(Create("SEG-1", 80));
- Segments.Add(Create("SEG-2", 20, brake: SpeedFunctionType.SCurve, sensor: SensorType.Color, action: SensorActionType.Stop));
- Segments.Add(Create("SEG-3", 55));
- Segments.Add(Create("SEG-4", 50, sensor: SensorType.Distance, action: SensorActionType.Slower, slow: 30));
- Segments.Add(Create("SEG-5", 90));
- Segments.Add(Create("SEG-6", 85, sensor: SensorType.Color, action: SensorActionType.Notify));
- Segments.Add(Create("SEG-7", 50));
- Segments.Add(Create("SEG-8", 55));
+ foreach (var goneStrand in StrandGroups.Where(g => !keep.Contains(g.StrandId)).ToList()) StrandGroups.Remove(goneStrand);
+
+ OnPropertyChanged(nameof(ActiveStrandName));
+ OnPropertyChanged(nameof(SelectedBranchName));
+ OnPropertyChanged(nameof(SelectedBranchCount));
+ }
+
+ private StrandGroup? Branch(TrackSegment? segment)
+ => segment?.BranchStrandId is { } id ? StrandGroups.FirstOrDefault(g => g.StrandId == id) : null;
+
+ private static void CopyInto(TrackSegment segment, TrackSegmentDto dto)
+ {
+ segment.Name = dto.Name;
+ segment.Type = dto.Type;
+ segment.MaxSpeed = dto.MaxSpeed;
+ segment.Direction = dto.Direction;
+ segment.AccelFn = dto.AccelFn;
+ segment.BrakeFn = dto.BrakeFn;
+ segment.Sensor = dto.Sensor;
+ segment.Action = dto.Action;
+ segment.SlowTarget = dto.SlowTarget;
+ segment.StrandId = dto.StrandId;
+ segment.Order = dto.Order;
+ segment.Curve = dto.Curve;
+ segment.BranchStrandId = dto.BranchStrandId;
+ segment.BranchDirection = dto.BranchDirection;
+ segment.Route = dto.Route;
+ segment.BranchLinkSegmentId = dto.BranchLinkSegmentId;
+ }
+
+ private static TrackSegment ToModel(TrackSegmentDto dto)
+ {
+ var segment = new TrackSegment { Id = dto.Id };
+ CopyInto(segment, dto);
+ return segment;
+ }
+
+ private static TrackSegmentDto ToDto(TrackSegment segment) => new()
+ {
+ Id = segment.Id,
+ Name = segment.Name,
+ Type = segment.Type,
+ MaxSpeed = segment.MaxSpeed,
+ Direction = segment.Direction,
+ AccelFn = segment.AccelFn,
+ BrakeFn = segment.BrakeFn,
+ Sensor = segment.Sensor,
+ Action = segment.Action,
+ SlowTarget = segment.SlowTarget,
+ StrandId = segment.StrandId,
+ Order = segment.Order,
+ Curve = segment.Curve,
+ BranchStrandId = segment.BranchStrandId,
+ BranchDirection = segment.BranchDirection,
+ Route = segment.Route,
+ BranchLinkSegmentId = segment.BranchLinkSegmentId,
+ };
+
+ partial void OnZoomFactorChanged(double value)
+ {
+ OnPropertyChanged(nameof(CanvasWidth));
+ OnPropertyChanged(nameof(CanvasHeight));
+ }
+
+ partial void OnActiveStrandIdChanged(Guid value)
+ {
+ foreach (var group in StrandGroups) group.IsActive = group.StrandId == value;
+ OnPropertyChanged(nameof(ActiveStrandName));
+ }
+
+ partial void OnSelectedSegmentChanged(TrackSegment? value)
+ {
+ OnPropertyChanged(nameof(SelectedBranchName));
+ OnPropertyChanged(nameof(SelectedBranchCount));
+ OnPropertyChanged(nameof(BranchLinkOptions));
}
private void SegmentOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
diff --git a/Source/Trackify/Presentation/ViewModels/StrandGroup.cs b/Source/Trackify/Presentation/ViewModels/StrandGroup.cs
new file mode 100644
index 0000000..08c53c5
--- /dev/null
+++ b/Source/Trackify/Presentation/ViewModels/StrandGroup.cs
@@ -0,0 +1,20 @@
+using System.Collections.ObjectModel;
+using TrackSegment = Trackify.Models.Trains.TrackSegment;
+
+namespace Trackify.Presentation.ViewModels;
+
+///
+/// One strand in the Streckenplaner: the main strand () or a Weiche's branch.
+/// Doubles as both the "Baue an" chip (Name/Count/IsActive) and the strand-grouped segment list's
+/// section header + rows.
+///
+public partial class StrandGroup : ObservableObject
+{
+ public required Guid StrandId { get; init; }
+
+ [ObservableProperty] private string name = "";
+ [ObservableProperty] private int count;
+ [ObservableProperty] private bool isActive;
+
+ public ObservableCollection Rows { get; } = [];
+}
diff --git a/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml b/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml
new file mode 100644
index 0000000..6b50169
--- /dev/null
+++ b/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml.cs b/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml.cs
new file mode 100644
index 0000000..fab4069
--- /dev/null
+++ b/Source/Trackify/Presentation/Widgets/BlueprintFrame.xaml.cs
@@ -0,0 +1,25 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Markup;
+
+namespace Trackify.Presentation.Widgets;
+
+///
+/// Wraps with the Industry design system's 4-corner "blueprint"
+/// registration marks (styles.css ".corner.tl/tr/bl/br") — the wireframe-card decoration used
+/// across every card, dialog and canvas frame in the "Trackify Mobile.dc.html" import.
+///
+[ContentProperty(Name = nameof(FrameContent))]
+public sealed partial class BlueprintFrame : UserControl
+{
+ public static readonly DependencyProperty FrameContentProperty = DependencyProperty.Register(
+ nameof(FrameContent), typeof(object), typeof(BlueprintFrame), new PropertyMetadata(null));
+
+ public BlueprintFrame() => this.InitializeComponent();
+
+ public object? FrameContent
+ {
+ get => GetValue(FrameContentProperty);
+ set => SetValue(FrameContentProperty, value);
+ }
+}
diff --git a/Source/Trackify/Styles/ColorPaletteOverride.xaml b/Source/Trackify/Styles/ColorPaletteOverride.xaml
index 12e5b0a..5178728 100644
--- a/Source/Trackify/Styles/ColorPaletteOverride.xaml
+++ b/Source/Trackify/Styles/ColorPaletteOverride.xaml
@@ -1,68 +1,71 @@
- #6750A4
- #FFFFFF
- #EADDFF
- #21005D
- #625B71
- #FFFFFF
- #E8DEF8
- #1D192B
- #7D5260
- #FFFFFF
- #FFD8E4
- #31111D
- #B3261E
- #F9DEDC
+ #5980A6
+ #F2F2F3
+ #EEF6FF
+ #2C455D
+ #728FAB
+ #F2F2F3
+ #EEF6FF
+ #314457
+ #486077
+ #F2F2F3
+ #D6EBFF
+ #1F2D3A
+ #E5484D
+ #FBE0E0#FFFFFF
- #410E0B
- #F3F0F8
- #1D1B20
- #FFFBFE
- #1D1B20
- #E7E0EC
- #49454F
- #79747E
- #F4EFF4
- #313033
- #D0BCFF
- #6750A4
- #CAC4D0
+ #7A1E20
+ #F2F2F3
+ #1D1F20
+ #E9E9EA
+ #1D1F20
+ #E7E7EA
+ #5D5D60
+ #98989B
+ #F2F2F3
+ #1D2D3D
+ #B5D9FD
+ #5980A6
+ #DADADB
- #D0BCFF
- #381E72
- #4F378B
- #EADDFF
- #CCC2DC
- #332D41
- #4A4458
- #E8DEF8
- #EFB8C8
- #492532
- #633B48
- #FFD8E4
- #F2B8B5
- #8C1D18
- #601410
- #F9DEDC
- #141218
- #E6E0E9
- #211F26
- #E6E0E9
- #49454F
- #CAC4D0
- #938F99
- #322F35
- #E6E1E5
- #6750A4
- #D0BCFF
- #49454F
+ #94BCE3
+ #1D2D3D
+ #2C455D
+ #EEF6FF
+ #9EBBD8
+ #1F2D3A
+ #314457
+ #EEF6FF
+ #7E9CB8
+ #1F2D3A
+ #486077
+ #EEF6FF
+ #F2A0A3
+ #5A1416
+ #3D0A0B
+ #FBE0E0
+ #1D1F20
+ #F2F2F3
+ #424244
+ #F2F2F3
+ #5D5D60
+ #D4D4D7
+ #98989B
+ #1D1F20
+ #F5F5F8
+ #597EA3
+ #94BCE3
+ #5D5D60
diff --git a/Source/Trackify/Styles/DesignTokens.xaml b/Source/Trackify/Styles/DesignTokens.xaml
index 91c1bf8..b95e013 100644
--- a/Source/Trackify/Styles/DesignTokens.xaml
+++ b/Source/Trackify/Styles/DesignTokens.xaml
@@ -3,35 +3,47 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
- #8A848F
+ #8A8A8D
- #948F99
+ #B7B7BA
-
- 8
- 12
- 16
- 20
- 26
+
+ 2
+ 0
+ 0
+ 0
+ 0Consolas,Courier New,Courier,monospace
-
+
+ Barlow Condensed,Segoe UI Semibold,Segoe UI,sans-serif
+
+
-
+
+
+
-
+
-
+
@@ -97,12 +121,14 @@
Segmented pill button (Alle/Aktiv/Inaktiv, Vorwärts/Rückwärts, Kein/Farbe/Abstand).
A plain command-driven Button rather than a themed ToggleButton/RadioButton: each instance
binds its own Background/Foreground via SegmentBackgroundConverter/SegmentForegroundConverter
- against the current enum value, and Click executes a "select this value" command.
+ against the current enum value, and Click executes a "select this value" command. Square,
+ border-first per .seg-opt (selected = filled accent, via the converters' selected branch).
-->