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}
Выберите состав с транспортом
Уровень только для этой части графики