using System; using System.Collections.Generic; using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; namespace Eu07.BlackBox.Views; /// /// The user's train over the time of the recording, by the clock of the simulation: its /// speed, the speed the AI would hold and the next limit it saw, below them bands of who /// drove and how against the AI, of the controller, the brakes, the alerter (CA), the cab /// signalling (SHP) and the camera, and marks of the commands, the signals passed, the /// stations, the horn points and the couplings. A click goes to its moment; the wheel zooms /// the time, dragging moves it. /// public sealed class TimelineView : Control { private const double Left = 56; private const double Right = 12; private const double Top = 22; private const double BandHeight = 8; private const double BandGap = 3; private const double MarksHeight = 16; private const double AxisHeight = 20; private const int Bands = 6; private Recording? _recording; private double _time; private double _start; private double _secondsPerPixel = -1; private Point? _pressed; private double _pressedStart; private bool _dragged; private double? _hover; private int _measured = -1; private Recording? _measuredOf; private double _speedTop = 40; private int _controllerTop = 1; /// A moment was clicked. public event Action? Seek; /// The moment under the pointer, null once it leaves. public event Action? Hovered; public TimelineView() { ClipToBounds = true; MinHeight = 160; } public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings(); public Recording? Recording { get => _recording; set { _recording = value; _secondsPerPixel = -1; _start = 0; InvalidateVisual(); } } public double Time { get => _time; set { _time = value; // Kept in view while it plays. if (_secondsPerPixel > 0 && _recording is not null) { double end = _start + PlotWidth * _secondsPerPixel; if (value > end || value < _start) _start = Math.Max(0, value - PlotWidth * _secondsPerPixel * 0.1); } InvalidateVisual(); } } /// All of the recording in view. public void Fit() { _secondsPerPixel = -1; _start = 0; InvalidateVisual(); } private double PlotWidth => Math.Max(10, Bounds.Width - Left - Right); private double ChartBottom => Bounds.Height - AxisHeight - MarksHeight - Bands * (BandHeight + BandGap) - 6; private double X(double t) => Left + (t - _start) / _secondsPerPixel; private double TimeAt(double x) => _start + (x - Left) * _secondsPerPixel; public override void Render(DrawingContext context) { var size = Bounds.Size; var recording = _recording; if (recording is null || recording.Duration <= 0) return; if (_secondsPerPixel <= 0) _secondsPerPixel = Math.Max(recording.Duration, 60) / PlotWidth; double end = _start + PlotWidth * _secondsPerPixel; double chartBottom = ChartBottom; Measure(recording); double top = _speedTop; double Y(double kmh) => chartBottom - Math.Clamp(kmh / top, 0, 1.05) * (chartBottom - Top); using (context.PushClip(new Rect(Left, 0, PlotWidth, size.Height))) { DrawGrid(context, top, Y, size); DrawCurves(context, recording, Y, end); double bands = chartBottom + 6; DrawBands(context, recording, bands, end); DrawMarks(context, recording, bands + Bands * (BandHeight + BandGap), end); DrawAxis(context, recording, size.Height - AxisHeight, end); if (_hover is { } hover) context.DrawLine(new Pen(Palette.DimText, 1, new DashStyle(new double[] { 2, 3 }, 0)), new Point(X(hover), Top), new Point(X(hover), size.Height - AxisHeight)); double cursor = X(_time); context.DrawLine(new Pen(Palette.Cursor, 1.5), new Point(cursor, Top - 6), new Point(cursor, size.Height - AxisHeight)); // The moment played, on the axis. var clock = MapView.Text(recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), Palette.Cursor, 10, FontWeight.SemiBold); var bubble = new Rect(Math.Clamp(cursor - clock.Width / 2 - 5, Left, size.Width - Right - clock.Width - 10), size.Height - AxisHeight + 2, clock.Width + 10, clock.Height + 2); context.DrawRectangle(Palette.Card, new Pen(Palette.Cursor, 1), bubble); context.DrawText(clock, new Point(bubble.X + 5, bubble.Y + 1)); } DrawSpeedLabels(context, top, Y); DrawBandLabels(context, chartBottom + 6); DrawKey(context); } /// The top of the speed scale and the highest step of the controller, once a reading. private void Measure(Recording recording) { if (_measured == recording.Revision && ReferenceEquals(_measuredOf, recording)) return; float max = 40; int controller = 1; foreach (var s in recording.Cab) { max = Math.Max(max, s.AbsSpeedKmh); if (s.AiValid) max = Math.Max(max, s.Ai.TargetKmh); controller = Math.Max(controller, (int)s.MainController); } _speedTop = Math.Ceiling(max * 1.1 / 20) * 20; _controllerTop = controller; _measured = recording.Revision; _measuredOf = recording; } private void DrawGrid(DrawingContext context, double top, Func y, Size size) { var pen = new Pen(Palette.Grid, 1); for (double v = 0; v <= top; v += top > 160 ? 40 : 20) context.DrawLine(pen, new Point(Left, y(v)), new Point(size.Width - Right, y(v))); } private void DrawSpeedLabels(DrawingContext context, double top, Func y) { for (double v = 0; v <= top; v += top > 160 ? 40 : 20) { var text = MapView.Text(v.ToString("0", CultureInfo.InvariantCulture), Palette.DimText, 10); context.DrawText(text, new Point(Left - 6 - text.Width, y(v) - text.Height / 2)); } } /// /// Each curve by columns of pixels: in a column the lowest and the highest speed, /// so an hour draws as fast as a minute. /// private void DrawCurves(DrawingContext context, Recording recording, Func y, double end) { var cab = recording.Cab; int first = Math.Max(0, recording.CabIndexAt(_start)); var speed = new Curve(); var target = new Curve(); var limits = new List(); int column = int.MinValue; CabSample? before = null; for (int i = first; i < cab.Count; i++) { var s = cab[i]; if (s.Time > end + _secondsPerPixel * 2) break; bool gap = before is { } b && (s.Time - b.Time > 1.0 || s.Vehicle != b.Vehicle); if (gap) { speed.Break(); target.Break(); } int c = (int)Math.Round(X(s.Time)); speed.Add(c, y(s.AbsSpeedKmh)); // Under the autopilot too: what it aimed at. if (s.AiValid) target.Add(c, y(s.Ai.TargetKmh)); else target.Break(); if (c != column && s.AiValid && s.Ai.HasNextLimit) limits.Add(new Point(c, y(s.Ai.NextLimitKmh))); column = c; before = s; } if (speed.Fill(y(0)) is { } fill) context.DrawGeometry(Palette.SpeedFill, null, fill); if (target.Geometry() is { } t) context.DrawGeometry(null, new Pen(Palette.Target, 1.5), t); var limitPen = new Pen(Palette.Limit, 2); foreach (var p in limits) context.DrawLine(limitPen, p, p + new Vector(1, 0)); if (speed.Geometry() is { } v) context.DrawGeometry(null, new Pen(Palette.Speed, 1.5), v); } private void DrawBands(DrawingContext context, Recording recording, double top, double end) { var cab = recording.Cab; int first = Math.Max(0, recording.CabIndexAt(_start)); var cameras = recording.Cameras; int camera = -1; var rows = new BandRun[Bands]; for (int row = 0; row < rows.Length; row++) rows[row] = new BandRun(context, top + row * (BandHeight + BandGap)); for (int i = first; i < cab.Count; i++) { var s = cab[i]; if (s.Time > end) break; double next = i + 1 < cab.Count && cab[i + 1].Time - s.Time <= 1.0 ? cab[i + 1].Time : s.Time + 0.1; double x0 = X(s.Time), x1 = Math.Max(X(next), x0 + 0.6); rows[0].Add(x0, x1, Palette.Of(DrivingSummary.Judge(s)), BandHeight); // The controller as high as its step stands against the highest one used. rows[1].Add(x0, x1, s.MainController > 0 ? Palette.Controller : null, Math.Max(1.5, BandHeight * s.MainController / _controllerTop)); rows[2].Add(x0, x1, s.BrakeCylinder > 0.2f ? BrakeShade(s.BrakeCylinder) : null, BandHeight); if (s.Security != SecurityFlags.None || s.CabEnd != 0) { // The alerter and the cab signalling apart, red when not acknowledged in time. rows[3].Add(x0, x1, s.Has(SecurityFlags.VigilanceBeeping) ? Palette.LampAlarm : s.Has(SecurityFlags.Vigilance) ? Palette.Vigilance : null, BandHeight); rows[4].Add(x0, x1, s.Has(SecurityFlags.CabSignalBeeping) ? Palette.LampAlarm : s.Has(SecurityFlags.CabSignal) ? Palette.CabSignal : null, BandHeight); if (s.Has(SecurityFlags.Braking)) rows[s.Has(SecurityFlags.CabSignal) && !s.Has(SecurityFlags.Vigilance) ? 4 : 3].Add(x0, x1, Palette.Bad, BandHeight); } else rows[3].Add(x0, x1, (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm : (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.Vigilance : null, BandHeight); // Where the user looked from: the samples go on in time, and so does the camera. while (camera + 1 < cameras.Count && cameras[camera + 1].Time <= s.Time) camera++; rows[5].Add(x0, x1, camera < 0 ? null : CameraBrush(cameras[camera].Mode), BandHeight); } foreach (var row in rows) row.Flush(); } private static readonly IBrush CameraCab = Palette.Faded(Palette.UserTrain, 0.45); private static readonly IBrush CameraOutside = Palette.Faded(Palette.HornPoint, 0.75); private static readonly IBrush CameraFree = Palette.Faded(Palette.Station, 0.75); private static IBrush CameraBrush(CameraMode mode) => mode switch { CameraMode.Cab => CameraCab, CameraMode.External => CameraOutside, _ => CameraFree }; private static readonly IBrush[] BrakeShades = Shades(Palette.Brakes, 8); private static IBrush[] Shades(IBrush brush, int count) { var colour = ((ISolidColorBrush)brush).Color; var shades = new IBrush[count]; for (int i = 0; i < count; i++) shades[i] = new SolidColorBrush(colour, 0.35 + 0.65 * i / (count - 1)); return shades; } /// The harder the brakes hold, the deeper the red. private static IBrush BrakeShade(float cylinderBar) => BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))]; /// /// The samples of a band drawn as few rectangles as the band changes: ten a second, each /// narrower than a pixel, they would leave seams between them. /// private sealed class BandRun(DrawingContext context, double top) { private IBrush? _brush; private double _height, _from, _to; public void Add(double x0, double x1, IBrush? brush, double height) { if (brush is not null && ReferenceEquals(brush, _brush) && height == _height && x0 <= _to + 0.5) { _to = Math.Max(_to, x1); return; } Flush(); _brush = brush; _height = height; _from = x0; _to = x1; } public void Flush() { if (_brush is not null) context.FillRectangle(_brush, new Rect(_from, top + BandHeight - _height, _to - _from, _height)); _brush = null; } } private void DrawBandLabels(DrawingContext context, double top) { string[] names = { Strings["BandDriving"], Strings["BandController"], Strings["BandBrakes"], Strings["BandVigilance"], Strings["BandCabSignal"], Strings["BandCamera"] }; for (int i = 0; i < names.Length; i++) { double y = top + i * (BandHeight + BandGap); context.DrawRectangle(Palette.Faded(Palette.Grid, 0.5), null, new Rect(Left, y, Bounds.Width - Left - Right, BandHeight)); var text = MapView.Text(names[i], Palette.FaintText, 9); context.DrawText(text, new Point(Left - 6 - text.Width, y + (BandHeight - text.Height) / 2)); } } /// /// The commands as short grey ticks, the autopilot switched in blue, the signals passed in /// green, at danger tall and red; the stations as blue plates, the horn points as diamonds /// green or red as the horn sounded, the couplings as yellow ticks. /// private void DrawMarks(DrawingContext context, Recording recording, double top, double end) { foreach (var station in recording.UserStations()) { if (station.Time < _start || station.Time > end || station.Kind == StationKind.Ready) continue; double x = X(station.Time); var brush = station.Kind == StationKind.Missed ? Palette.Bad : Palette.Station; context.DrawLine(new Pen(Palette.Faded(brush, 0.35), 1), new Point(x, Top), new Point(x, top)); context.DrawRectangle(brush, null, new Rect(x - 3, Top - 13, 6, 8), 1.5, 1.5); } foreach (var horn in recording.UserHornChecks()) { if (horn.Request.Time < _start || horn.Request.Time > end) continue; double x = X(horn.Request.Time); var brush = horn.Sounded ? Palette.Good : Palette.Bad; var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(new Point(x, Top - 14), true); g.LineTo(new Point(x + 4, Top - 10)); g.LineTo(new Point(x, Top - 6)); g.LineTo(new Point(x - 4, Top - 10)); g.EndFigure(true); } context.DrawGeometry(brush, null, geometry); } var coupling = new Pen(Palette.Warn, 2); foreach (var c in recording.UserCouplings()) if (c.Time >= _start && c.Time <= end) context.DrawLine(coupling, new Point(X(c.Time), top + 1), new Point(X(c.Time), top + MarksHeight)); var command = new Pen(Palette.Command, 1); foreach (var c in recording.UserCommands()) if (c.Time >= _start && c.Time <= end) context.DrawLine(command, new Point(X(c.Time), top + MarksHeight - 5), new Point(X(c.Time), top + MarksHeight)); var autopilot = new Pen(Palette.Autopilot, 2); foreach (var d in recording.DriverChanges) if (d.Time >= _start && d.Time <= end) context.DrawLine(autopilot, new Point(X(d.Time), top + 2), new Point(X(d.Time), top + MarksHeight)); foreach (var p in recording.UserPassings()) { if (p.Time < _start || p.Time > end) continue; double x = X(p.Time); if (p.AtDanger) { var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(new Point(x, Top - 4), true); g.LineTo(new Point(x - 5, Top - 13)); g.LineTo(new Point(x + 5, Top - 13)); g.EndFigure(true); } context.DrawGeometry(Palette.Over, null, geometry); context.DrawLine(new Pen(Palette.Over, 1.5), new Point(x, Top - 4), new Point(x, top + MarksHeight)); } else context.DrawLine(new Pen(Palette.Within, 1.5), new Point(x, top + 1), new Point(x, top + MarksHeight - 6)); } } private void DrawAxis(DrawingContext context, Recording recording, double y, double end) { double[] steps = { 10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200 }; double step = steps[^1]; foreach (double candidate in steps) if (candidate / _secondsPerPixel >= 80) { step = candidate; break; } var pen = new Pen(Palette.DimText, 1); // On the clock of the simulation: the marks fall on whole minutes and hours of it. double offset = recording.ClockAt(0).TotalSeconds; double first = Math.Ceiling((_start + offset) / step) * step - offset; for (double t = first; t <= end; t += step) { double x = X(t); context.DrawLine(pen, new Point(x, y), new Point(x, y + 4)); var clock = recording.ClockAt(t); string label = step < 60 ? clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture); var text = MapView.Text(label, Palette.DimText, 10); context.DrawText(text, new Point(x - text.Width / 2, y + 4)); } } private void DrawKey(DrawingContext context) { double x = Left + 4; foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") }) { context.DrawLine(new Pen(brush, 2), new Point(x, 10), new Point(x + 14, 10)); var text = MapView.Text(Strings[key], Palette.DimText, 10); context.DrawText(text, new Point(x + 18, 10 - text.Height / 2)); x += 18 + text.Width + 14; } } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; if (e.ClickCount == 2) { Fit(); return; } _pressed = e.GetPosition(this); _pressedStart = _start; _dragged = false; e.Pointer.Capture(this); } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); if (_recording is null || _secondsPerPixel <= 0) return; var position = e.GetPosition(this); if (_pressed is { } from) { double moved = position.X - from.X; if (Math.Abs(moved) > 3) _dragged = true; if (_dragged) _start = Math.Clamp(_pressedStart - moved * _secondsPerPixel, 0, Math.Max(0, _recording.Duration - PlotWidth * _secondsPerPixel * 0.5)); } _hover = position.X >= Left ? Math.Clamp(TimeAt(position.X), 0, _recording.Duration) : null; Hovered?.Invoke(_hover); InvalidateVisual(); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (_pressed is not null && !_dragged && _recording is not null && _secondsPerPixel > 0) Seek?.Invoke(Math.Clamp(TimeAt(e.GetPosition(this).X), 0, _recording.Duration)); _pressed = null; e.Pointer.Capture(null); } protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); _hover = null; Hovered?.Invoke(null); InvalidateVisual(); } protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); if (_recording is null || _secondsPerPixel <= 0) return; double x = e.GetPosition(this).X; double at = TimeAt(x); double whole = Math.Max(_recording.Duration, 60) / PlotWidth; _secondsPerPixel = Math.Clamp(_secondsPerPixel / Math.Pow(1.3, e.Delta.Y), 0.02, whole); _start = Math.Max(0, at - (x - Left) * _secondsPerPixel); InvalidateVisual(); e.Handled = true; } /// A line through columns of pixels: in each the first, the lowest, the highest and the last value. private sealed class Curve { private readonly List> _lines = new(); private List? _line; private int _column = int.MinValue; private double _first, _low, _high, _last; public void Add(int column, double y) { if (column != _column) { Flush(); _column = column; _first = _low = _high = y; } _low = Math.Min(_low, y); _high = Math.Max(_high, y); _last = y; } public void Break() { Flush(); _line = null; _column = int.MinValue; } private void Flush() { if (_column == int.MinValue) return; if (_line is null) _lines.Add(_line = new List()); var (a, b) = _first <= _last ? (_low, _high) : (_high, _low); _line.Add(new Point(_column, _first)); if (Math.Abs(_high - _low) > 0.5) { _line.Add(new Point(_column, a)); _line.Add(new Point(_column, b)); } _line.Add(new Point(_column, _last)); } /// The area under the line down to the bottom given, for a soft fill. public StreamGeometry? Fill(double bottom) { Flush(); _column = int.MinValue; var geometry = new StreamGeometry(); bool any = false; using (var g = geometry.Open()) { foreach (var line in _lines) { if (line.Count < 2) continue; g.BeginFigure(new Point(line[0].X, bottom), true); foreach (var point in line) g.LineTo(point); g.LineTo(new Point(line[^1].X, bottom)); g.EndFigure(true); any = true; } } return any ? geometry : null; } public StreamGeometry? Geometry() { Flush(); _column = int.MinValue; var geometry = new StreamGeometry(); bool any = false; using (var g = geometry.Open()) { foreach (var line in _lines) { if (line.Count < 2) continue; g.BeginFigure(line[0], false); for (int i = 1; i < line.Count; i++) g.LineTo(line[i]); g.EndFigure(false); any = true; } } return any ? geometry : null; } } }