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

This commit is contained in:
maj00r
2026-11-11 17:55:56 +01:00
parent 9872b316c2
commit fa36284de1
8 changed files with 161 additions and 4 deletions

View File

@@ -291,6 +291,9 @@ public sealed class BlackBoxWindow : Window
/// <summary>Goes to a moment of the recording, in seconds from its start.</summary> /// <summary>Goes to a moment of the recording, in seconds from its start.</summary>
public void SeekTo(double t) => SetTime(t); public void SeekTo(double t) => SetTime(t);
/// <summary>The moment shown has changed: played on, sought or read further.</summary>
public event Action<double>? TimeChanged;
/// <summary>Keeps the user's train in the middle of the map.</summary> /// <summary>Keeps the user's train in the middle of the map.</summary>
public bool FollowTrain public bool FollowTrain
{ {
@@ -453,6 +456,7 @@ public sealed class BlackBoxWindow : Window
_events.ShowTime(_time); _events.ShowTime(_time);
ShowReadout(null); ShowReadout(null);
ShowDelay(recording); ShowDelay(recording);
TimeChanged?.Invoke(_time);
} }
/// <summary>How late the user's train was at the moment played, by the clock.</summary> /// <summary>How late the user's train was at the moment played, by the clock.</summary>

View File

@@ -61,6 +61,7 @@ public sealed class HaslerTapeWindow : Window
private readonly Dictionary<DateTime, bool> _expanded = new(); private readonly Dictionary<DateTime, bool> _expanded = new();
private readonly Dictionary<string, BlackBoxWindow> _blackBoxes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, BlackBoxWindow> _blackBoxes = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase); private HashSet<string> _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<TapeSession> _sessions = Array.Empty<TapeSession>();
private TapeFile? _shown; private TapeFile? _shown;
private string? _followed; private string? _followed;
@@ -138,6 +139,7 @@ public sealed class HaslerTapeWindow : Window
_view = NewView(RecorderKind.Hasler); _view = NewView(RecorderKind.Hasler);
_view.Scale = DefaultKmScale; _view.Scale = DefaultKmScale;
_view.Hovered += ShowCursor; _view.Hovered += ShowCursor;
_view.Picked += PickOnTape;
_scroll = new ScrollViewer _scroll = new ScrollViewer
{ {
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
@@ -286,9 +288,11 @@ public sealed class HaslerTapeWindow : Window
var old = _view; var old = _view;
old.Hovered -= ShowCursor; old.Hovered -= ShowCursor;
old.Picked -= PickOnTape;
_view = NewView(Recorder); _view = NewView(Recorder);
_view.CopySettingsFrom(old); _view.CopySettingsFrom(old);
_view.Hovered += ShowCursor; _view.Hovered += ShowCursor;
_view.Picked += PickOnTape;
_scroll.Content = _view; _scroll.Content = _view;
// The other view is not as tall; an offset kept from it would hide its top rows. // 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); _scroll.Offset = new Vector(_scroll.Offset.X, 0);
@@ -313,6 +317,7 @@ public sealed class HaslerTapeWindow : Window
private void ShowSessions(IReadOnlyList<TapeSession> sessions, bool toDriven) private void ShowSessions(IReadOnlyList<TapeSession> sessions, bool toDriven)
{ {
_running = sessions.Any(session => session.Running); _running = sessions.Any(session => session.Running);
_sessions = sessions;
_listed = Fingerprint(sessions); _listed = Fingerprint(sessions);
ShowWhatIsKnown(); ShowWhatIsKnown();
@@ -346,6 +351,7 @@ public sealed class HaslerTapeWindow : Window
{ {
_shown = null; _shown = null;
_view.Tape = null; _view.Tape = null;
_view.Mark = null;
_summary.Text = ""; _summary.Text = "";
return; return;
} }
@@ -413,11 +419,16 @@ public sealed class HaslerTapeWindow : Window
return item; return item;
} }
/// <summary>The black box in its window, read on while the session runs; one window a file.</summary> /// <summary>
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.
/// </summary>
private async Task OpenBlackBoxAsync(BlackBoxRecording box, double? at = null)
{ {
if (_blackBoxes.TryGetValue(box.Path, out var open)) if (_blackBoxes.TryGetValue(box.Path, out var open))
{ {
if (at is { } moment)
open.SeekTo(moment);
open.Activate(); open.Activate();
return; return;
} }
@@ -427,9 +438,73 @@ public sealed class HaslerTapeWindow : Window
Live = _liveBlackBoxes.Contains(box.Path) Live = _liveBlackBoxes.Contains(box.Path)
}; };
_blackBoxes[box.Path] = window; _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); window.Show(this);
await window.OpenAsync(box.Path); await window.OpenAsync(box.Path);
if (at is { } t)
window.SeekTo(t);
}
/// <summary>The session of the tape shown, with its black boxes; null for a tape no list has any more.</summary>
private TapeSession? ShownSession =>
_shown is { } shown
? _sessions.FirstOrDefault(session => session.Tapes.Any(tape => tape.Path == shown.Path))
: null;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Where a black box of the session shown plays, on its tape, brought into view as it moves on.</summary>
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();
}
/// <summary>The mark of an open black box of the session shown, or none, on a tape just shown or changed.</summary>
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);
} }
/// <summary>The newest black box of a running session is still written; once the session ends, its last part is read and that is all.</summary> /// <summary>The newest black box of a running session is still written; once the session ends, its last part is read and that is all.</summary>
@@ -496,11 +571,13 @@ public sealed class HaslerTapeWindow : Window
if (tape is null) if (tape is null)
{ {
_view.Tape = null; _view.Tape = null;
_view.Mark = null;
_summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path); _summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path);
return; return;
} }
_view.Tape = tape; _view.Tape = tape;
ShowMark(scrollTo: !scrollToEnd);
_summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration), _summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration),
TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime), TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime),
tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh, tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh,
@@ -588,6 +665,8 @@ public sealed class HaslerTapeWindow : Window
var copy = NewView(Recorder); var copy = NewView(Recorder);
copy.CopySettingsFrom(_view); 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. // The trip it was asked for: a reload may have come in behind the file picker.
copy.Tape = tape; copy.Tape = tape;
copy.Measure(Size.Infinity); copy.Measure(Size.Infinity);
@@ -631,6 +710,8 @@ public sealed class HaslerTapeWindow : Window
s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm)) s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm))
: string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar, : string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar,
YesNo(s.Current)); 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"]; private static string YesNo(bool on) => on ? App.Loc["HaslerYes"] : App.Loc["HaslerNo"];

View File

@@ -31,12 +31,21 @@ public abstract class TapeView : Control
public static readonly StyledProperty<TimeSpan> StartClockProperty = public static readonly StyledProperty<TimeSpan> StartClockProperty =
AvaloniaProperty.Register<TapeView, TimeSpan>(nameof(StartClock)); AvaloniaProperty.Register<TapeView, TimeSpan>(nameof(StartClock));
/// <summary>
/// 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.
/// </summary>
public static readonly StyledProperty<double?> MarkProperty =
AvaloniaProperty.Register<TapeView, double?>(nameof(Mark));
private static readonly IPen CursorPen = new Pen(new SolidColorBrush(Color.Parse("#FFB000")), 1); 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() static TapeView()
{ {
AffectsMeasure<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty); AffectsMeasure<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty);
AffectsRender<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty); AffectsRender<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty, MarkProperty);
} }
public HaslerTape? Tape public HaslerTape? Tape
@@ -63,9 +72,18 @@ public abstract class TapeView : Control
set => SetValue(StartClockProperty, value); set => SetValue(StartClockProperty, value);
} }
public double? Mark
{
get => GetValue(MarkProperty);
set => SetValue(MarkProperty, value);
}
/// <summary>The sample under the pointer, or null once it leaves the tape.</summary> /// <summary>The sample under the pointer, or null once it leaves the tape.</summary>
public event Action<TapeSample?>? Hovered; public event Action<TapeSample?>? Hovered;
/// <summary>The sample clicked on.</summary>
public event Action<TapeSample>? Picked;
protected abstract double LeftMargin { get; } protected abstract double LeftMargin { get; }
private const double RightMargin = 24; private const double RightMargin = 24;
@@ -86,6 +104,23 @@ public abstract class TapeView : Control
private double PositionOf(double x) => private double PositionOf(double x) =>
ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0); ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0);
/// <summary>
/// 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.
/// </summary>
public double? MarkX => Mark is { } t ? XAt(t) : null;
/// <summary>Where a moment of the trip stands across the tape; null outside the trip.</summary>
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;
}
/// <summary>Seconds since midnight at the sample, by the clock the trip started at.</summary> /// <summary>Seconds since midnight at the sample, by the clock the trip started at.</summary>
public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0); public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0);
@@ -96,6 +131,7 @@ public abstract class TapeView : Control
ByDistance = other.ByDistance; ByDistance = other.ByDistance;
Scale = other.Scale; Scale = other.Scale;
StartClock = other.StartClock; StartClock = other.StartClock;
Mark = other.Mark;
} }
protected override Size MeasureOverride(Size availableSize) => protected override Size MeasureOverride(Size availableSize) =>
@@ -110,6 +146,7 @@ public abstract class TapeView : Control
return; return;
DrawTape(context, tape); DrawTape(context, tape);
DrawMark(context);
DrawCursor(context); DrawCursor(context);
} }
@@ -133,6 +170,19 @@ public abstract class TapeView : Control
Hovered?.Invoke(index < 0 ? null : tape.Samples[index]); 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) protected override void OnPointerExited(PointerEventArgs e)
{ {
base.OnPointerExited(e); base.OnPointerExited(e);
@@ -141,6 +191,23 @@ public abstract class TapeView : Control
Hovered?.Invoke(null); Hovered?.Invoke(null);
} }
/// <summary>A line across the tape with a notch on top, in a colour of its own: not the pointer's.</summary>
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) private void DrawCursor(DrawingContext context)
{ {
if (CursorX is { } x) if (CursorX is { } x)

View File

@@ -852,6 +852,7 @@
<String key="HaslerSessionRunning">běží</String> <String key="HaslerSessionRunning">běží</String>
<String key="HaslerBlackBox">Černá skříňka</String> <String key="HaslerBlackBox">Černá skříňka</String>
<String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String> <String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String>
<String key="HaslerClickBlackBox">klikněte: černá skříňka v tomto okamžiku</String>
<String key="WarehouseSaved">Ve skladu: {0}</String> <String key="WarehouseSaved">Ve skladu: {0}</String>
<String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String> <String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String>
<String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String> <String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String>

View File

@@ -1081,6 +1081,7 @@
<String key="HaslerSessionRunning">running</String> <String key="HaslerSessionRunning">running</String>
<String key="HaslerBlackBox">Black box</String> <String key="HaslerBlackBox">Black box</String>
<String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String> <String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String>
<String key="HaslerClickBlackBox">click: the black box at this moment</String>
<String key="WarehouseSaved">In the warehouse: {0}</String> <String key="WarehouseSaved">In the warehouse: {0}</String>
<String key="WarehouseNothingToAdd">Pick a consist with vehicles</String> <String key="WarehouseNothingToAdd">Pick a consist with vehicles</String>
<String key="GfxGroupLevelTip">Level for this part of the graphics only</String> <String key="GfxGroupLevelTip">Level for this part of the graphics only</String>

View File

@@ -852,6 +852,7 @@
<String key="HaslerSessionRunning">folyamatban</String> <String key="HaslerSessionRunning">folyamatban</String>
<String key="HaslerBlackBox">Fekete doboz</String> <String key="HaslerBlackBox">Fekete doboz</String>
<String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String> <String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String>
<String key="HaslerClickBlackBox">kattintás: a fekete doboz ebben a pillanatban</String>
<String key="WarehouseSaved">A raktárban: {0}</String> <String key="WarehouseSaved">A raktárban: {0}</String>
<String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String> <String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String>
<String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String> <String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String>

View File

@@ -903,6 +903,7 @@
<String key="HaslerSessionRunning">trwa</String> <String key="HaslerSessionRunning">trwa</String>
<String key="HaslerBlackBox">Czarna skrzynka</String> <String key="HaslerBlackBox">Czarna skrzynka</String>
<String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String> <String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String>
<String key="HaslerClickBlackBox">kliknij: czarna skrzynka w tej chwili</String>
<String key="WarehouseSaved">W magazynie: {0}</String> <String key="WarehouseSaved">W magazynie: {0}</String>
<String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String> <String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String>
<String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String> <String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String>

View File

@@ -852,6 +852,7 @@
<String key="HaslerSessionRunning">идёт</String> <String key="HaslerSessionRunning">идёт</String>
<String key="HaslerBlackBox">Чёрный ящик</String> <String key="HaslerBlackBox">Чёрный ящик</String>
<String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String> <String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String>
<String key="HaslerClickBlackBox">щелчок: чёрный ящик в этот момент</String>
<String key="WarehouseSaved">На складе: {0}</String> <String key="WarehouseSaved">На складе: {0}</String>
<String key="WarehouseNothingToAdd">Выберите состав с транспортом</String> <String key="WarehouseNothingToAdd">Выберите состав с транспортом</String>
<String key="GfxGroupLevelTip">Уровень только для этой части графики</String> <String key="GfxGroupLevelTip">Уровень только для этой части графики</String>