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 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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}"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -282,8 +361,10 @@ + + - + @@ -58,29 +59,6 @@ - - - - - - - - - + @@ -49,7 +52,7 @@ - + @@ -79,15 +82,23 @@ - + + - + + + + @@ -97,27 +108,28 @@ - - - - - - - - - - + @@ -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 + 0 Consolas,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). -->