From fa36284de18d1148dafbe729d78f4eb93dcc3246 Mon Sep 17 00:00:00 2001 From: maj00r Date: Wed, 11 Nov 2026 17:55:56 +0100 Subject: [PATCH] feat(tapes): a click on the tape takes the black box of its session to that moment, and where the black box plays is marked on the tape --- Eu07.BlackBox.Views/BlackBoxWindow.cs | 4 ++ StarterNG/Views/Tapes/HaslerTapeWindow.cs | 87 ++++++++++++++++++++++- StarterNG/Views/Tapes/TapeView.cs | 69 +++++++++++++++++- StarterNG/startercfg/lang/cz.xml | 1 + StarterNG/startercfg/lang/en.xml | 1 + StarterNG/startercfg/lang/hu.xml | 1 + StarterNG/startercfg/lang/pl.xml | 1 + StarterNG/startercfg/lang/ru.xml | 1 + 8 files changed, 161 insertions(+), 4 deletions(-) diff --git a/Eu07.BlackBox.Views/BlackBoxWindow.cs b/Eu07.BlackBox.Views/BlackBoxWindow.cs index 2f09c9b..987a2aa 100644 --- a/Eu07.BlackBox.Views/BlackBoxWindow.cs +++ b/Eu07.BlackBox.Views/BlackBoxWindow.cs @@ -291,6 +291,9 @@ public sealed class BlackBoxWindow : Window /// Goes to a moment of the recording, in seconds from its start. public void SeekTo(double t) => SetTime(t); + /// The moment shown has changed: played on, sought or read further. + public event Action? TimeChanged; + /// Keeps the user's train in the middle of the map. public bool FollowTrain { @@ -453,6 +456,7 @@ public sealed class BlackBoxWindow : Window _events.ShowTime(_time); ShowReadout(null); ShowDelay(recording); + TimeChanged?.Invoke(_time); } /// How late the user's train was at the moment played, by the clock. diff --git a/StarterNG/Views/Tapes/HaslerTapeWindow.cs b/StarterNG/Views/Tapes/HaslerTapeWindow.cs index fe308c4..e9ed6a4 100644 --- a/StarterNG/Views/Tapes/HaslerTapeWindow.cs +++ b/StarterNG/Views/Tapes/HaslerTapeWindow.cs @@ -61,6 +61,7 @@ public sealed class HaslerTapeWindow : Window private readonly Dictionary _expanded = new(); private readonly Dictionary _blackBoxes = new(StringComparer.OrdinalIgnoreCase); private HashSet _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase); + private IReadOnlyList _sessions = Array.Empty(); private TapeFile? _shown; private string? _followed; @@ -138,6 +139,7 @@ public sealed class HaslerTapeWindow : Window _view = NewView(RecorderKind.Hasler); _view.Scale = DefaultKmScale; _view.Hovered += ShowCursor; + _view.Picked += PickOnTape; _scroll = new ScrollViewer { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, @@ -286,9 +288,11 @@ public sealed class HaslerTapeWindow : Window var old = _view; old.Hovered -= ShowCursor; + old.Picked -= PickOnTape; _view = NewView(Recorder); _view.CopySettingsFrom(old); _view.Hovered += ShowCursor; + _view.Picked += PickOnTape; _scroll.Content = _view; // The other view is not as tall; an offset kept from it would hide its top rows. _scroll.Offset = new Vector(_scroll.Offset.X, 0); @@ -313,6 +317,7 @@ public sealed class HaslerTapeWindow : Window private void ShowSessions(IReadOnlyList sessions, bool toDriven) { _running = sessions.Any(session => session.Running); + _sessions = sessions; _listed = Fingerprint(sessions); ShowWhatIsKnown(); @@ -346,6 +351,7 @@ public sealed class HaslerTapeWindow : Window { _shown = null; _view.Tape = null; + _view.Mark = null; _summary.Text = ""; return; } @@ -413,11 +419,16 @@ public sealed class HaslerTapeWindow : Window return item; } - /// The black box in its window, read on while the session runs; one window a file. - private async Task OpenBlackBoxAsync(BlackBoxRecording box) + /// + /// The black box in its window, read on while the session runs; one window a file. Given a moment, it opens + /// there. Where it plays is marked on a tape of the same session. + /// + private async Task OpenBlackBoxAsync(BlackBoxRecording box, double? at = null) { if (_blackBoxes.TryGetValue(box.Path, out var open)) { + if (at is { } moment) + open.SeekTo(moment); open.Activate(); return; } @@ -427,9 +438,73 @@ public sealed class HaslerTapeWindow : Window Live = _liveBlackBoxes.Contains(box.Path) }; _blackBoxes[box.Path] = window; - window.Closed += (_, _) => _blackBoxes.Remove(box.Path); + window.TimeChanged += t => FollowBlackBox(box, t); + window.Closed += (_, _) => + { + _blackBoxes.Remove(box.Path); + ShowMark(scrollTo: false); + }; window.Show(this); await window.OpenAsync(box.Path); + if (at is { } t) + window.SeekTo(t); + } + + /// The session of the tape shown, with its black boxes; null for a tape no list has any more. + private TapeSession? ShownSession => + _shown is { } shown + ? _sessions.FirstOrDefault(session => session.Tapes.Any(tape => tape.Path == shown.Path)) + : null; + + /// + /// A click on the tape takes the black box of its session to the same moment, opening it if no window has it. + /// Both count seconds of the simulation from its start, so a tape's own time is the black box's. + /// + private void PickOnTape(TapeSample sample) + { + if (ShownSession is not { BlackBoxes.Count: > 0 } session) + return; + var open = session.BlackBoxes.Where(box => _blackBoxes.ContainsKey(box.Path)).ToList(); + if (open.Count == 0) + { + _ = OpenBlackBoxAsync(session.BlackBoxes[0], sample.Time); + return; + } + foreach (var box in open) + _blackBoxes[box.Path].SeekTo(sample.Time); + } + + /// Where a black box of the session shown plays, on its tape, brought into view as it moves on. + private void FollowBlackBox(BlackBoxRecording box, double t) + { + if (ShownSession?.BlackBoxes.Any(b => b.Path == box.Path) != true) + return; + // Played, it moves every frame: the tape is drawn again only when the mark moves on it. + double? x = _view.XAt(t), was = _view.MarkX; + if (x is { } now && was is { } before && Math.Abs(now - before) < 0.5) + return; + _view.Mark = t; + BringMarkIntoView(); + } + + /// The mark of an open black box of the session shown, or none, on a tape just shown or changed. + private void ShowMark(bool scrollTo) + { + var window = ShownSession?.BlackBoxes.Select(box => _blackBoxes.GetValueOrDefault(box.Path)) + .FirstOrDefault(w => w is not null); + _view.Mark = window?.CurrentTime; + if (scrollTo) + BringMarkIntoView(); + } + + private void BringMarkIntoView() + { + if (_view.MarkX is not { } x) + return; + double left = _scroll.Offset.X, width = _scroll.Viewport.Width; + if (width <= 0 || (x >= left + 16 && x <= left + width - 16)) + return; + _scroll.Offset = new Vector(Math.Max(0, x - width / 2), _scroll.Offset.Y); } /// The newest black box of a running session is still written; once the session ends, its last part is read and that is all. @@ -496,11 +571,13 @@ public sealed class HaslerTapeWindow : Window if (tape is null) { _view.Tape = null; + _view.Mark = null; _summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path); return; } _view.Tape = tape; + ShowMark(scrollTo: !scrollToEnd); _summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration), TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime), tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh, @@ -588,6 +665,8 @@ public sealed class HaslerTapeWindow : Window var copy = NewView(Recorder); copy.CopySettingsFrom(_view); + // The paper alone, not where a black box happens to be. + copy.Mark = null; // The trip it was asked for: a reload may have come in behind the file picker. copy.Tape = tape; copy.Measure(Size.Infinity); @@ -631,6 +710,8 @@ public sealed class HaslerTapeWindow : Window s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm)) : string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar, YesNo(s.Current)); + if (ShownSession is { BlackBoxes.Count: > 0 }) + _cursor.Text += " " + App.Loc["HaslerClickBlackBox"]; } private static string YesNo(bool on) => on ? App.Loc["HaslerYes"] : App.Loc["HaslerNo"]; diff --git a/StarterNG/Views/Tapes/TapeView.cs b/StarterNG/Views/Tapes/TapeView.cs index 63ab02f..88446c2 100644 --- a/StarterNG/Views/Tapes/TapeView.cs +++ b/StarterNG/Views/Tapes/TapeView.cs @@ -31,12 +31,21 @@ public abstract class TapeView : Control public static readonly StyledProperty StartClockProperty = AvaloniaProperty.Register(nameof(StartClock)); + /// + /// The moment a black box of the same run is at, in the tape's own seconds: both count the simulation from + /// its start, paused or not. Null draws nothing. + /// + public static readonly StyledProperty MarkProperty = + AvaloniaProperty.Register(nameof(Mark)); + private static readonly IPen CursorPen = new Pen(new SolidColorBrush(Color.Parse("#FFB000")), 1); + private static readonly IBrush MarkBrush = new SolidColorBrush(Color.Parse("#4FC3F7")); + private static readonly IPen MarkPen = new Pen(MarkBrush, 2); static TapeView() { AffectsMeasure(TapeProperty, ByDistanceProperty, ScaleProperty); - AffectsRender(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty); + AffectsRender(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty, MarkProperty); } public HaslerTape? Tape @@ -63,9 +72,18 @@ public abstract class TapeView : Control set => SetValue(StartClockProperty, value); } + public double? Mark + { + get => GetValue(MarkProperty); + set => SetValue(MarkProperty, value); + } + /// The sample under the pointer, or null once it leaves the tape. public event Action? Hovered; + /// The sample clicked on. + public event Action? Picked; + protected abstract double LeftMargin { get; } private const double RightMargin = 24; @@ -86,6 +104,23 @@ public abstract class TapeView : Control private double PositionOf(double x) => ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0); + /// + /// Where the mark stands across the tape; null with no mark or outside the trip. By distance it is where the + /// train was at that moment, so through a stop the mark stands still. + /// + public double? MarkX => Mark is { } t ? XAt(t) : null; + + /// Where a moment of the trip stands across the tape; null outside the trip. + public double? XAt(double t) + { + var tape = Tape; + if (tape is null || tape.IsEmpty || t < tape.StartTime || t > tape.Samples[^1].Time) + return null; + return ByDistance + ? XOf(tape.Samples[tape.IndexAt(t, byDistance: false)]) + : LeftMargin + (t - tape.StartTime) / 60.0 * Scale; + } + /// Seconds since midnight at the sample, by the clock the trip started at. public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0); @@ -96,6 +131,7 @@ public abstract class TapeView : Control ByDistance = other.ByDistance; Scale = other.Scale; StartClock = other.StartClock; + Mark = other.Mark; } protected override Size MeasureOverride(Size availableSize) => @@ -110,6 +146,7 @@ public abstract class TapeView : Control return; DrawTape(context, tape); + DrawMark(context); DrawCursor(context); } @@ -133,6 +170,19 @@ public abstract class TapeView : Control Hovered?.Invoke(index < 0 ? null : tape.Samples[index]); } + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var tape = Tape; + if (tape is null || tape.IsEmpty || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + + double x = Math.Clamp(e.GetPosition(this).X, LeftMargin, TapeRight); + int index = tape.IndexAt(PositionOf(x), ByDistance); + if (index >= 0) + Picked?.Invoke(tape.Samples[index]); + } + protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); @@ -141,6 +191,23 @@ public abstract class TapeView : Control Hovered?.Invoke(null); } + /// A line across the tape with a notch on top, in a colour of its own: not the pointer's. + private void DrawMark(DrawingContext context) + { + if (MarkX is not { } x) + return; + context.DrawLine(MarkPen, new Point(x, 0), new Point(x, TapeHeight)); + var notch = new StreamGeometry(); + using (var g = notch.Open()) + { + g.BeginFigure(new Point(x - 5, 0), true); + g.LineTo(new Point(x + 5, 0)); + g.LineTo(new Point(x, 7)); + g.EndFigure(true); + } + context.DrawGeometry(MarkBrush, null, notch); + } + private void DrawCursor(DrawingContext context) { if (CursorX is { } x) diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index 8547393..efda283 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -852,6 +852,7 @@ běží Černá skříňka Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina + klikněte: černá skříňka v tomto okamžiku Ve skladu: {0} Vyberte soupravu s vozidly Úroveň jen pro tuto část grafiky diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 9fd4ec1..e58483e 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -1081,6 +1081,7 @@ running Black box The whole session on a map: the driving against the AI, the commands, the signals, the cab + click: the black box at this moment In the warehouse: {0} Pick a consist with vehicles Level for this part of the graphics only diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index 1099d59..4c1d5a7 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -852,6 +852,7 @@ folyamatban Fekete doboz A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke + kattintás: a fekete doboz ebben a pillanatban A raktárban: {0} Válassz járműveket tartalmazó szerelvényt Csak a grafika ezen részének szintje diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index 30cad9d..902329a 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -903,6 +903,7 @@ trwa Czarna skrzynka Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina + kliknij: czarna skrzynka w tej chwili W magazynie: {0} Wybierz skład z pojazdami Poziom tylko tej części grafiki diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index 372b63f..6eca9ac 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -852,6 +852,7 @@ идёт Чёрный ящик Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина + щелчок: чёрный ящик в этот момент На складе: {0} Выберите состав с транспортом Уровень только для этой части графики