using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; namespace Eu07.BlackBox.Views; /// /// Replays a black box. Above: the train and the scenery, playing and how late the train /// was. In the middle the map at the moment played, the timeline of the user's train under /// it; beside them the events, the timetable or the figures of the driving, and the cab. A file /// the simulator still writes can be followed: what it adds is read every two seconds, and /// playing at its end keeps up with it. /// public sealed class BlackBoxWindow : Window { private static readonly double[] Speeds = { 1, 2, 5, 10, 30, 60 }; private readonly IBlackBoxStrings _strings; private readonly MapView _map; private readonly TimelineView _timeline; private readonly EventList _events; private readonly TimetablePanel _timetable; private readonly ScrollViewer _timetableScroll; private readonly ScrollViewer _summaryScroll; private readonly CabPanel _cab; private readonly UiKit.Segmented _tabs; private readonly TextBlock _title = new() { FontSize = 14, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, TextTrimming = TextTrimming.CharacterEllipsis }; private readonly TextBlock _subtitle = new() { FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis }; private readonly TextBlock _clock = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, MinWidth = 86, TextAlignment = TextAlignment.Center }; private readonly TextBlock _delayText = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 }; private readonly TextBlock _readout = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis }; private readonly TextBlock _fileState = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.FaintText }; private readonly TextBlock _message = new() { FontSize = 14, Foreground = Palette.DimText, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Margin = new Thickness(40) }; private readonly StackPanel _tiles = new(); private readonly UiKit.Tile _distance, _maxSpeed, _driven, _overAi, _signals, _stops, _delay, _platform, _lights, _horn, _acceleration, _security, _state; private readonly Button _open; private readonly Button _play; private readonly ComboBox _speed = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 64, FontSize = 12, Padding = new Thickness(8, 3) }; private readonly Avalonia.Controls.Primitives.ToggleButton _follow; private readonly Avalonia.Controls.Primitives.ToggleButton _live; private readonly DispatcherTimer _tick = new() { Interval = TimeSpan.FromMilliseconds(40) }; private readonly DispatcherTimer _reload = new() { Interval = TimeSpan.FromSeconds(2) }; private readonly Stopwatch _played = new(); private BlackBoxReader? _reader; private string? _path; private double _time; private bool _reading; public BlackBoxWindow() : this(null) { } public BlackBoxWindow(IBlackBoxStrings? strings) { _strings = strings ?? new DefaultBlackBoxStrings(); Title = _strings["Title"]; Width = 1440; Height = 900; MinWidth = 960; MinHeight = 600; Background = Palette.Background; UiKit.Accent(this); WindowStartupLocation = WindowStartupLocation.CenterOwner; _map = new MapView { Strings = _strings }; _timeline = new TimelineView { Strings = _strings }; _events = new EventList { Strings = _strings, Background = Brushes.Transparent }; _timetable = new TimetablePanel { Strings = _strings }; _timetableScroll = new ScrollViewer { Content = _timetable, IsVisible = false }; _summaryScroll = new ScrollViewer { Content = _tiles, IsVisible = false }; _cab = new CabPanel { Strings = _strings }; // ----- the bar on top: the train, playing, the switches ----- _open = UiKit.Button(_strings["Open"]); _open.Click += async (_, _) => await PickAsync(); var reload = UiKit.Button(_strings["Reload"]); reload.Click += (_, _) => ReadMore(); var fit = UiKit.Button(_strings["Fit"]); fit.Click += (_, _) => ShowWhole(); _follow = UiKit.Toggle(_strings["Follow"]); _follow.IsCheckedChanged += (_, _) => _map.Follow = _follow.IsChecked == true; _map.FollowStopped += () => _follow.IsChecked = false; _live = UiKit.Toggle(_strings["Live"], _strings["LiveTip"]); _live.IsCheckedChanged += (_, _) => { if (_live.IsChecked == true) _reload.Start(); else _reload.Stop(); }; _play = UiKit.PlayButton(); _play.Content = UiKit.PlayGlyph(false); ToolTip.SetTip(_play, _strings["Play"]); _play.Click += (_, _) => Playing = !Playing; _speed.ItemsSource = Speeds.Select(s => $"×{s.ToString(CultureInfo.InvariantCulture)}").ToArray(); _speed.SelectedIndex = 3; var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Children = { _title, _subtitle } }; var transport = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, VerticalAlignment = VerticalAlignment.Center, Children = { _play, _clock, _speed, _delayText } }; var switches = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { _follow, _live, fit, reload, _open } }; var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") }; Place(header, titles, 0, 0); Place(header, transport, 0, 1); switches.HorizontalAlignment = HorizontalAlignment.Right; Place(header, switches, 0, 2); titles.Margin = new Thickness(0, 0, 16, 0); var headerBar = new Border { Background = Palette.Panel, BorderBrush = Palette.CardBorder, BorderThickness = new Thickness(0, 0, 0, 1), Padding = new Thickness(8, 0), Height = 44, Child = header }; // ----- the figures, a table in a tab of their own ----- _distance = Tile("StatDistance"); _maxSpeed = Tile("StatMaxSpeed"); _driven = Tile("StatDriven"); _overAi = Tile("StatOverAi"); _signals = Tile("StatSignals"); _stops = Tile("StatStops"); _delay = Tile("StatDelay"); _platform = Tile("StatPlatform"); _lights = Tile("StatLights"); _horn = Tile("StatHorn"); _acceleration = Tile("StatAcceleration"); _security = Tile("StatSecurity"); _state = Tile("StatRecording"); _timeline.Seek += t => SetTime(t); _timeline.Hovered += ShowReadout; _events.Seek += t => SetTime(t); _timetable.Seek += t => SetTime(t); _tick.Tick += (_, _) => Advance(); _reload.Tick += (_, _) => ReadMore(); // ----- the map with its zoom, the timeline, the side ----- var zoomIn = MapButton("+", _strings["ZoomIn"], () => _map.ZoomBy(1.6)); var zoomOut = MapButton("−", _strings["ZoomOut"], () => _map.ZoomBy(1 / 1.6)); var zoomFit = MapButton("⤢", _strings["Fit"], ShowWhole); var zoom = new StackPanel { Spacing = 1, Margin = new Thickness(8), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, Children = { zoomIn, zoomOut, zoomFit } }; var mapArea = new Panel { Children = { _map, zoom, _message } }; _map.ClipToBounds = true; _tabs = new UiKit.Segmented(_strings["TabEvents"], _strings["TabTimetable"], _strings["TabSummary"]); _tabs.SelectedChanged += index => { _events.IsVisible = index == 0; _timetableScroll.IsVisible = index == 1; _summaryScroll.IsVisible = index == 2; }; var side = new DockPanel(); DockPanel.SetDock(_tabs, Dock.Top); side.Children.Add(_tabs); side.Children.Add(new Panel { Children = { _events, _timetableScroll, _summaryScroll } }); var cabCard = UiKit.Group(_strings["Cab"], _cab); cabCard.Margin = new Thickness(0, UiKit.Gap, 0, 0); var sideColumn = new DockPanel(); DockPanel.SetDock(cabCard, Dock.Bottom); sideColumn.Children.Add(cabCard); sideColumn.Children.Add(UiKit.Frame(side)); var body = new Grid { ColumnDefinitions = new ColumnDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},400"), RowDefinitions = new RowDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},230") }; Place(body, UiKit.Group(_strings["PanelMap"], mapArea), 0, 0); Place(body, new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Rows, MinHeight = UiKit.Gap }, 1, 0); Place(body, UiKit.Group(_strings["PanelTimeline"], _timeline), 2, 0); var columns = new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Columns, MinWidth = UiKit.Gap }; Place(body, columns, 0, 1); Grid.SetRowSpan(columns, 3); Place(body, sideColumn, 0, 2); Grid.SetRowSpan(sideColumn, 3); // ----- the line at the bottom: the moment under the pointer, the state of the file ----- var status = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; Place(status, _readout, 0, 0); Place(status, _fileState, 0, 1); var statusBar = new Border { Background = Palette.Panel, BorderBrush = Palette.CardBorder, BorderThickness = new Thickness(0, 1, 0, 0), Padding = new Thickness(12, 3), Child = status }; body.Margin = new Thickness(UiKit.Gap); var root = new DockPanel { Background = Palette.Background }; DockPanel.SetDock(headerBar, Dock.Top); DockPanel.SetDock(statusBar, Dock.Bottom); root.Children.Add(headerBar); root.Children.Add(statusBar); root.Children.Add(body); Content = root; _title.Text = _strings["Title"]; _subtitle.Text = _strings["NoFile"]; _message.Text = _strings["NoFile"]; Closed += (_, _) => { _tick.Stop(); _reload.Stop(); }; } // When and at which end the signals were wrong: "10:55:12 head Tb 1 instead of Pc 1". private string LightsFaultText(Recording recording, LightsFault fault, CultureInfo culture) { var ends = new List(); if (fault.HeadWrong) ends.Add(string.Format(culture, _strings["StatLightsHead"], HeadCodes.Name(fault.Head), HeadCodes.Name(fault.HeadRequired))); if (fault.TailWrong) ends.Add(string.Format(culture, _strings["StatLightsTail"], HeadCodes.Name(fault.Tail), HeadCodes.Name(fault.TailRequired))); return $"{Clock(recording.ClockAt(fault.Start).TotalSeconds)} {string.Join(", ", ends)}"; } private UiKit.Tile Tile(string key) { var tile = new UiKit.Tile(_strings[key]); _tiles.Children.Add(tile); return tile; } private static Button MapButton(string text, string tip, Action action) { var button = UiKit.Button(text, tip); button.Width = 28; button.Height = 28; button.Padding = new Thickness(0); button.FontSize = 15; button.HorizontalContentAlignment = HorizontalAlignment.Center; button.VerticalContentAlignment = VerticalAlignment.Center; button.Background = Palette.Panel; button.Click += (_, _) => action(); return button; } private static void Place(Grid grid, Control control, int row, int column) { Grid.SetRow(control, row); Grid.SetColumn(control, column); grid.Children.Add(control); } /// The button for opening another file: the starter opens the one picked in its list. public bool CanOpenFiles { get => _open.IsVisible; set => _open.IsVisible = value; } /// Reads what the simulator adds to the file every two seconds. public bool Live { get => _live.IsChecked == true; set => _live.IsChecked = value; } public string? FilePath => _path; public Recording? Recording => _reader?.Recording; public double CurrentTime => _time; /// Goes to a moment of the recording, in seconds from its start. public void SeekTo(double t) => SetTime(t); /// Keeps the user's train in the middle of the map. public bool FollowTrain { get => _follow.IsChecked == true; set => _follow.IsChecked = value; } /// The timetable beside the map instead of the events. public bool ShowTimetable { get => _tabs.Selected == 1; set => _tabs.Selected = value ? 1 : 0; } /// The summary of the driving beside the map instead of the events. public bool SummaryShown { get => _tabs.Selected == 2; set => _tabs.Selected = value ? 2 : 0; } private bool Playing { get => _tick.IsEnabled; set { if (value == _tick.IsEnabled) return; if (value) { if (Recording is { } r && _time >= r.Duration - 0.05 && !Live) SetTime(0); _played.Restart(); _tick.Start(); } else _tick.Stop(); _play.Content = UiKit.PlayGlyph(value); ToolTip.SetTip(_play, _strings[value ? "Pause" : "Play"]); } } private void ShowWhole() { _follow.IsChecked = false; _map.Fit(); _timeline.Fit(); } /// Opens a black box, read whole on another thread. public async Task OpenAsync(string path) { Playing = false; ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Reading"], Path.GetFileName(path))); try { var reader = await Task.Run(() => { var r = new BlackBoxReader(); r.ReadMore(path); return r; }); _reader = reader; _path = path; Title = string.Format(CultureInfo.CurrentCulture, _strings["TitleOf"], Path.GetFileName(path)); _map.Recording = reader.Recording; _timeline.Recording = reader.Recording; _events.Recording = reader.Recording; _timetable.Recording = reader.Recording; _cab.Recording = reader.Recording; _message.IsVisible = false; // A file still written opens at its end, where the train is now. SetTime(Live ? reader.Recording.Duration : 0); ShowSummary(); } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], path, ex.Message)); } } private void ShowMessage(string text) { _message.Text = text; _message.IsVisible = true; _subtitle.Text = text; _fileState.Text = ""; } /// What the simulator added since the last read. Small, so read here, between frames. public void ReadMore() { if (_reader is not { } reader || _path is null || _reading) return; _reading = true; try { bool atEnd = _time >= reader.Recording.Duration - 0.5; if (reader.ReadMore(_path) == 0) return; _events.Refresh(); if (atEnd && !Playing) SetTime(reader.Recording.Duration); else SetTime(_time); ShowSummary(); } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], _path, ex.Message)); } finally { _reading = false; } } private async Task PickAsync() { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = _strings["Open"], AllowMultiple = false, FileTypeFilter = new[] { new FilePickerFileType(_strings["FileFilter"]) { Patterns = new[] { "*.blackbox" } } } }); if (files.Count > 0 && files[0].TryGetLocalPath() is { } path) await OpenAsync(path); } private void Advance() { if (Recording is not { } recording) return; double real = _played.Elapsed.TotalSeconds; _played.Restart(); double speed = Speeds[Math.Clamp(_speed.SelectedIndex, 0, Speeds.Length - 1)]; double next = _time + real * speed; if (next >= recording.Duration) { next = recording.Duration; // At the end of a file still written it waits for more. if (!Live) Playing = false; } SetTime(next); } private void SetTime(double t) { var recording = Recording; double duration = recording?.Duration ?? 0; _time = Math.Clamp(t, 0, duration); _map.Time = _time; _timeline.Time = _time; _cab.Time = _time; _timetable.Time = _time; if (recording is null) return; _clock.Text = recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); _events.ShowTime(_time); ShowReadout(null); ShowDelay(recording); } /// How late the user's train was at the moment played, by the clock. private void ShowDelay(Recording recording) { if (StationProgress.DelayAt(recording, _time) is not { } delay) { _delayText.Text = ""; return; } var culture = CultureInfo.CurrentCulture; var brush = Palette.Delay(delay.Minutes); _delayText.Text = Math.Abs(delay.Minutes) < 0.5 ? _strings["OnTime"] : string.Format(culture, _strings["DelayNow"], string.Format(culture, _strings["Minutes"], delay.Minutes)); _delayText.Foreground = brush; ToolTip.SetTip(_delayText, string.Format(culture, _strings[delay.Standing ? "DelayStanding" : "DelayAfter"], delay.Train, StationNames.Display(delay.Station))); } /// The user's train at the moment under the pointer of the timeline, or at the moment played. private void ShowReadout(double? hovered) { if (Recording is not { } recording) return; double t = hovered ?? _time; string clock = recording.ClockAt(t).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); var culture = CultureInfo.CurrentCulture; if (recording.CabAt(t) is not { } s || t - s.Time > 1.0) { _readout.Text = string.Format(culture, _strings["CursorOut"], clock); return; } if (!s.AiValid) { _readout.Text = string.Format(culture, _strings["CursorNoAi"], clock, s.AbsSpeedKmh); return; } string limit = s.Ai.HasNextLimit ? string.Format(culture, _strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance) : _strings["None"]; _readout.Text = string.Format(culture, _strings["Cursor"], clock, s.AbsSpeedKmh, s.Ai.TargetKmh.ToString("0", culture) + " km/h", limit); } private void ShowSummary() { if (Recording is not { } recording) return; var summary = recording.Summarize(); var culture = CultureInfo.CurrentCulture; // The scenery, the train and the vehicle the user started in. string scenery = recording.Info?.Scenery ?? Path.GetFileName(_path) ?? ""; _title.Text = Path.GetFileNameWithoutExtension(scenery.TrimStart('$')); var timetable = recording.TimetableAt(_time); string vehicle = recording.Cab.Count > 0 ? recording.Vehicle(recording.Cab[^1].Vehicle)?.Name ?? "" : recording.Info?.StartVehicle ?? ""; var parts = new[] { timetable is null ? null : $"{timetable.Category} {timetable.Train}".Trim(), timetable is null || timetable.From.Length + timetable.To.Length == 0 ? null : string.Format(culture, _strings["Relation"], timetable.From, timetable.To), vehicle, recording.Info?.Started.Replace('T', ' ') }; _subtitle.Text = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); _distance.Show($"{summary.DistanceKm:0.0} km"); _maxSpeed.Show($"{summary.MaxSpeedKmh:0} km/h"); _driven.Show(Clock(summary.DrivenSeconds), string.Format(culture, _strings["StatAutopilotSub"], Clock(summary.AutopilotSeconds))); _overAi.Show(Clock(summary.OverAiSeconds), string.Format(culture, _strings["StatOverAiSub"], Clock(summary.WellOverAiSeconds), DrivingSummary.DefaultTolerance, summary.MaxOverAiKmh), summary.WellOverAiSeconds > 0 ? Palette.Bad : summary.OverAiSeconds > 0 ? Palette.Warn : Palette.Good); _signals.Show(summary.SignalsPassed.ToString(culture), string.Format(culture, _strings["StatSignalsSub"], summary.PassedAtDanger.Count), summary.PassedAtDanger.Count > 0 ? Palette.Bad : null); _stops.IsVisible = recording.Timetables.Count > 0; _stops.Show(summary.Stops.ToString(culture), string.Format(culture, _strings["StatStopsSub"], summary.StopsMissed), summary.StopsMissed > 0 ? Palette.Bad : null); _delay.IsVisible = summary.MaxDelayMinutes is not null; if (summary.MaxDelayMinutes is { } delay) _delay.Show(string.Format(culture, _strings["Minutes"], delay), summary.MaxDepartureDelayMinutes is { } departure ? string.Format(culture, _strings["StatDelaySub"], string.Format(culture, _strings["Minutes"], departure)) : null, Palette.Delay(delay)); _platform.IsVisible = summary.Stops > 0; _platform.Show(summary.OffPlatform.Count.ToString(culture), summary.OffPlatform.Count > 0 ? string.Join(" · ", summary.OffPlatform.Select(p => string.Format(culture, _strings["StatPlatformAt"], p.Station, p.Metres))) : _strings["StatPlatformNone"], summary.OffPlatform.Count > 0 ? Palette.Bad : Palette.Good); _lights.IsVisible = summary.LightsJudgedSeconds > 0; _lights.Show(summary.WrongLightsSeconds > 0 ? Clock(summary.WrongLightsSeconds) : _strings["StatLightsRight"], summary.LightsFaults.Count > 0 ? string.Join(" · ", summary.LightsFaults.Take(3).Select(f => LightsFaultText(recording, f, culture))) + (summary.LightsFaults.Count > 3 ? " …" : "") : string.Format(culture, _strings["StatLightsSub"], Clock(summary.LightsJudgedSeconds)), summary.WrongLightsSeconds > 0 ? Palette.Bad : Palette.Good); _horn.IsVisible = summary.HornPoints > 0; _horn.Show($"{summary.HornPoints - summary.HornsMissed}/{summary.HornPoints}", string.Format(culture, _strings["StatHornSub"], summary.HornsMissed), summary.HornsMissed > 0 ? Palette.Bad : Palette.Good); _acceleration.Show($"+{summary.MaxAcceleration:0.00} / −{summary.MaxDeceleration:0.00}", string.Format(culture, _strings["StatAccelerationSub"], summary.MaxLateralAcceleration)); _security.Show(summary.SecurityBrakings.ToString(culture), null, summary.SecurityBrakings > 0 ? Palette.Bad : Palette.Good); _state.Show(Clock(recording.Duration), _strings[recording.Complete ? "StatComplete" : "StatIncomplete"], recording.Complete ? null : Palette.Warn); _fileState.Text = $"{Path.GetFileName(_path)} · {Clock(recording.Duration)} · {_strings[recording.Complete ? "StatComplete" : "StatIncomplete"]}"; } private static string Clock(double seconds) { var span = TimeSpan.FromSeconds(Math.Round(seconds)); return span.TotalHours >= 1 ? span.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture) : span.ToString(@"m\:ss", CultureInfo.InvariantCulture); } }