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 timetable of the user's train, a row a station: what the timetable said and what /// happened, by the end of the recording read so far. A stop shows the time of arrival and /// of departure with the delay, how well the train was stopped against the stop point and /// whether it stood along the platform; a station passed through the time of passing. The /// station of the moment played is marked; a click goes to the moment the train was there. /// public sealed class TimetablePanel : Control { private const double Pad = 12; private const double HeaderHeight = 50; private const double RowHeight = 54; private Recording? _recording; private double _time; private int _builtRevision = -1; private IReadOnlyList _rows = Array.Empty(); private Timetable? _timetable; private int _hover = -1; public event Action? Seek; public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings(); public TimetablePanel() { ClipToBounds = true; Cursor = new Cursor(StandardCursorType.Hand); } public Recording? Recording { get => _recording; set { _recording = value; _builtRevision = -1; Rebuild(); } } public double Time { get => _time; set { _time = value; Rebuild(); InvalidateVisual(); } } private void Rebuild() { if (_recording is not { } recording) { _rows = Array.Empty(); _timetable = null; InvalidateMeasure(); return; } var timetable = recording.TimetableAt(_time); if (recording.Revision == _builtRevision && ReferenceEquals(timetable, _timetable)) return; _builtRevision = recording.Revision; _timetable = timetable; _rows = StationProgress.Of(recording, _time); InvalidateMeasure(); InvalidateVisual(); } protected override Size MeasureOverride(Size availableSize) => new(0, _timetable is null ? 80 : HeaderHeight + _rows.Count * RowHeight + Pad); /// The row of the station the train was at, or made for, at the moment played. private int Current() { int current = -1; for (int i = 0; i < _rows.Count; i++) { var row = _rows[i]; double? at = row.ArrivedAt ?? row.DepartedAt; if (at is { } t && t <= _time + 0.05) current = i; } // Still standing there, or on the way to the next one. if (current >= 0 && _rows[current].DepartedAt is { } left && left <= _time && current + 1 < _rows.Count) return current + 1; return current < 0 ? (_rows.Count > 0 ? 0 : -1) : current; } public override void Render(DrawingContext context) { var size = Bounds.Size; var culture = CultureInfo.CurrentCulture; if (_timetable is not { } timetable) { context.DrawText(MapView.Text(Strings["TimetableNone"], Palette.DimText, 12), new Point(Pad, Pad)); return; } // The train: its number and category, from where to where. var train = MapView.Text($"{timetable.Category} {timetable.Train}".Trim(), Palette.Text, 15, FontWeight.SemiBold); context.DrawText(train, new Point(Pad, 6)); string relation = string.Format(culture, Strings["Relation"], timetable.From, timetable.To); if (timetable.Vmax > 0) relation += $" · {timetable.Vmax:0} km/h"; context.DrawText(MapView.Text(relation, Palette.DimText, 11), new Point(Pad, 6 + train.Height + 1)); int current = Current(); double rail = Pad + 6; for (int i = 0; i < _rows.Count; i++) { double top = HeaderHeight + i * RowHeight; var row = _rows[i]; if (i == current) { context.DrawRectangle(Palette.CardHover, null, new Rect(0, top, size.Width, RowHeight - 1)); context.DrawRectangle(Palette.Accent, null, new Rect(0, top, 2, RowHeight - 1)); } else if (i == _hover) context.DrawRectangle(Palette.Faded(Palette.CardHover, 0.6), null, new Rect(0, top, size.Width, RowHeight - 1)); // The line of the route through the stations, a dot each: hollow ahead, filled when done. var outcome = row.Outcome; var colour = outcome switch { StationOutcome.Missed => Palette.Bad, StationOutcome.Ahead => Palette.FaintText, _ => DelayBrush(row.ArrivalDelay ?? row.DepartureDelay) }; double dotY = top + 15; if (i + 1 < _rows.Count) context.DrawLine(new Pen(Palette.Grid, 2), new Point(rail, dotY), new Point(rail, dotY + RowHeight)); bool stop = row.Row.IsStop; double radius = stop ? 5.5 : 3.5; context.DrawEllipse(outcome == StationOutcome.Ahead ? Palette.Card : colour, new Pen(colour, 2), new Point(rail, dotY), radius, radius); // The station and what the timetable said. double x = rail + 16; var name = MapView.Text(StationNames.Display(row.Row.Station), outcome == StationOutcome.Ahead ? Palette.DimText : Palette.Text, 13, stop ? FontWeight.SemiBold : FontWeight.Normal); context.DrawText(name, new Point(x, dotY - name.Height / 2)); string planned = stop ? $"{Minutes(row.Row.Arrival)} → {Minutes(row.Row.Departure)}" : Minutes(row.Row.Departure); var plan = MapView.Text(planned, Palette.DimText, 12); context.DrawText(plan, new Point(size.Width - Pad - plan.Width, dotY - plan.Height / 2)); if (row.Row.Km > 0 || i > 0) { var km = MapView.Text($"km {row.Row.Km:0.0}", Palette.FaintText, 10); double kmX = size.Width - Pad - plan.Width - 12 - km.Width; if (kmX > x + name.Width + 8) context.DrawText(km, new Point(kmX, dotY - km.Height / 2)); } // What happened there, under it: each part in its colour, on to a second line when long. double partX = x, partY = top + 25; bool first = true; foreach (var (text, brush) in Chips(row, culture)) { var part = MapView.Text(first ? text : "· " + text, brush, 11); if (!first && partX + part.Width > size.Width - Pad) { partX = x; partY += 14; part = MapView.Text(text, brush, 11); } context.DrawText(part, new Point(partX, partY)); partX += part.Width + 6; first = false; } } } /// What happened at a station, each part with its colour. private IEnumerable<(string Text, IBrush Brush)> Chips(StationProgress row, CultureInfo culture) { switch (row.Outcome) { case StationOutcome.Ahead: if (row.Row.Maintenance) yield return (Strings["TtTechnical"], Palette.FaintText); yield break; case StationOutcome.Passed: yield return (string.Format(culture, Strings["TtPassed"], Clock(row.DepartureClock)), Palette.DimText); if (row.DepartureDelay is { } passDelay) yield return (string.Format(culture, Strings["Minutes"], passDelay), DelayBrush(passDelay)); yield break; } if (row.ArrivedAt is null) { yield return (Strings["TtMissed"], Palette.Bad); yield break; } yield return (string.Format(culture, Strings["TtArrived"], Clock(row.ArrivalClock)), Palette.DimText); if (row.ArrivalDelay is { } arrival) yield return (string.Format(culture, Strings["Minutes"], arrival), DelayBrush(arrival)); if (row.Outcome == StationOutcome.Missed) yield return (Strings["TtLeftEarly"], Palette.Bad); else if (row.DepartedAt is not null) { yield return (string.Format(culture, Strings["TtDeparted"], Clock(row.DepartureClock)), Palette.DimText); if (row.DepartureDelay is { } departure) yield return (string.Format(culture, Strings["Minutes"], departure), DelayBrush(departure)); } else yield return (Strings["TtStanding"], Palette.Station); if (row.StopError is { } error) yield return Math.Abs(error) < 1 ? (Strings["TtStopExact"], Palette.Good) : (string.Format(culture, Strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error)), Math.Abs(error) <= 5 ? Palette.Good : Math.Abs(error) <= 15 ? Palette.Warn : Palette.Bad); if (row.OnPlatform is { } along) yield return along ? (Strings["TtOnPlatform"], Palette.Good) : (string.Format(culture, Strings["TtOffPlatform"], row.OffPlatform ?? 0), Palette.Bad); } private static IBrush DelayBrush(double? minutes) => Palette.Delay(minutes); private static string Minutes(float minutes) => minutes < 0 ? "–" : TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm", CultureInfo.InvariantCulture); private static string Clock(double? minutes) => minutes is { } m ? TimeSpan.FromMinutes(m % 1440).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : "–"; private int RowAt(Point p) { int i = (int)Math.Floor((p.Y - HeaderHeight) / RowHeight); return p.Y >= HeaderHeight && i >= 0 && i < _rows.Count ? i : -1; } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); int hover = RowAt(e.GetPosition(this)); if (hover != _hover) { _hover = hover; InvalidateVisual(); } } protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); _hover = -1; InvalidateVisual(); } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); int i = RowAt(e.GetPosition(this)); if (i >= 0 && (_rows[i].ArrivedAt ?? _rows[i].DepartedAt) is { } t) Seek?.Invoke(Math.Max(0, t - 5)); } }