Play back the black box of the simulator: map, cab, timeline, timetable and a summary

The simulator writes a black box of each run to physicslog/*.blackbox
(blackbox_doc.md in the maszyna repository). Three new projects read and
show it:

- Eu07.BlackBox: the reader of the records, the recording as a whole
  (the user's vehicle and consist at a time, the timetable shown, the
  progress along it with arrival and departure delays, whether the
  train stood at the platform), the light patterns Pc 1, Pc 2, Pc 5 and
  Tb 1 with the ones the run asks for, and a summary of the driving.
- Eu07.BlackBox.Views: the player window, with the scenery map (tracks,
  signals, W4 stop points with their heading and W32, the train, the
  cameras), the cab, the timeline of speed against the AI's reference,
  the events, the timetable and the summary.
- Eu07.BlackBox.Viewer: the window on its own, with options for the
  time, following the train and screenshots.

The tapes window of the starter lists the black boxes of each session
beside the Hasler tapes and opens them in the player.
This commit is contained in:
maj00r
2026-11-11 00:39:35 +01:00
parent e7d8596652
commit e530b95352
40 changed files with 6953 additions and 20 deletions

View File

@@ -0,0 +1,435 @@
using System.Collections.Generic;
using System.Globalization;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The texts of the window, by key. The starter gives its own translations; on its
/// own the window speaks Polish or English, as the system does.
/// </summary>
public interface IBlackBoxStrings
{
string this[string key] { get; }
}
public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
{
private static readonly Dictionary<string, string> English = new()
{
["Title"] = "Black box",
["TitleOf"] = "Black box – {0}",
["Open"] = "Open…",
["Reload"] = "Reload",
["Live"] = "Follow the file",
["LiveTip"] = "Reads what the simulator adds to the file every 2 seconds",
["Play"] = "Play",
["Pause"] = "Pause",
["Follow"] = "Follow the train",
["Fit"] = "Whole map",
["NoFile"] = "Open a black box: physicslog/*.blackbox, written with the physics logs on.",
["Unreadable"] = "Can't read {0}: {1}",
["Reading"] = "Reading {0}…",
["Summary"] = "{0} · {1:0.0} km · max {2:0} km/h · driven {3} · autopilot {4} · over the AI {5} ({6} by more than {7:0} km/h, at most +{8:0} km/h) · signals {9} · at danger {10} · commands {11}",
["NotComplete"] = "still being written, or cut short",
["Cursor"] = "{0} · {1:0} km/h · AI {2} · limit {3}",
["CursorNoAi"] = "{0} · {1:0} km/h · no AI data",
["CursorOut"] = "{0} · not in a vehicle",
["None"] = "–",
["LimitAt"] = "{0:0} km/h in {1:0} m",
["EventCommand"] = "{0}",
["EventAutopilotOn"] = "Autopilot on",
["EventAutopilotOff"] = "Autopilot off",
["EventPassed"] = "Signal {0}: {1}, {2:0} km/h",
["EventDanger"] = "PASSED AT DANGER {0}, {1:0} km/h, {2:0} m beyond",
["EventInVehicle"] = "In {0}",
["EventOutside"] = "Out of the cab of {0}",
["EventNoVehicle"] = "Not in any vehicle",
["EventEnd"] = "End of the recording",
["AspectStop"] = "stop",
["AspectFree"] = "go",
["AspectSpeed"] = "{0:0}/{1}",
["LegendWithin"] = "within the AI's speed",
["LegendSlightly"] = "up to {0:0} km/h over",
["LegendOver"] = "more over",
["LegendAutopilot"] = "autopilot",
["LegendNoData"] = "no AI data",
["TimelineSpeed"] = "speed",
["TimelineTarget"] = "AI",
["TimelineLimit"] = "next limit",
["BandDriving"] = "driving",
["BandBrakes"] = "brakes",
["BandAlerter"] = "alerter",
["BandController"] = "controller",
["Cab"] = "Cab",
["CabNoData"] = "The user was in no vehicle then.",
["CabController"] = "Master controller",
["CabShunt"] = "Field shunting",
["CabReverser"] = "Reverser",
["CabForward"] = "forward",
["CabBackward"] = "backward",
["CabNeutral"] = "0",
["CabTrainBrake"] = "Driver's brake valve",
["CabLocalBrake"] = "Local brake",
["CabReservoir"] = "Main reservoir",
["CabCylinder"] = "Brake cylinders",
["CabPipe"] = "Brake pipe",
["CabCurrent"] = "Current",
["CabVoltage"] = "Voltage",
["CabRpm"] = "Revolutions",
["CabLights"] = "Lights",
["CabLightsFront"] = "front",
["CabLightsRear"] = "rear",
["DevBattery"] = "Battery",
["DevMainBreaker"] = "Main breaker",
["DevPantographFront"] = "Front pantograph",
["DevPantographRear"] = "Rear pantograph",
["DevConverter"] = "Converter",
["DevCompressor"] = "Compressor",
["DevLineContactors"] = "Line contactors",
["DevHeating"] = "Train heating",
["DevRadio"] = "Radio",
["DevShuntMode"] = "Shunting mode",
["DevDoorsLeft"] = "Left doors",
["DevDoorsRight"] = "Right doors",
["DevSanding"] = "Sanding",
["DevHorn"] = "Horn",
["DevSpringBrake"] = "Spring brake",
["DevAlerter"] = "Alerter",
["DevWheelSlip"] = "Wheel slip",
["DevOverloadRelay"] = "Overload relay",
["DevRadioStop"] = "Radio-stop",
["DevAlarmChain"] = "Emergency brake",
["TabEvents"] = "Events",
["TabTimetable"] = "Timetable",
["TabSummary"] = "Summary",
["PanelMap"] = "Map",
["PanelTimeline"] = "Run",
["Relation"] = "{0} → {1}",
["StatDistance"] = "Distance",
["StatMaxSpeed"] = "Top speed",
["StatDriven"] = "Driven by the user",
["StatAutopilotSub"] = "autopilot {0}",
["StatOverAi"] = "Over the AI",
["StatOverAiSub"] = "{0} by >{1:0} km/h · up to +{2:0}",
["StatSignals"] = "Signals",
["StatSignalsSub"] = "{0} at danger",
["StatStops"] = "Stops",
["StatStopsSub"] = "{0} missed",
["StatDelay"] = "Largest delay",
["StatDelaySub"] = "leaving a station: {0} at most",
["StatPlatform"] = "Stops off the platform",
["StatLights"] = "Head and tail signals",
["StatLightsRight"] = "right",
["StatLightsSub"] = "as required all the {0} on the move",
["StatLightsHead"] = "head {0} instead of {1}",
["StatLightsTail"] = "tail {0} instead of {1}",
["StatPlatformAt"] = "{0} {1:0.0} m",
["StatPlatformNone"] = "the whole train along the platform at every stop",
["StatHorn"] = "Horn at W6",
["StatHornSub"] = "{0} without the horn",
["StatAcceleration"] = "Acceleration",
["StatAccelerationSub"] = "lateral up to {0:0.00} m/s²",
["StatSecurity"] = "CA/SHP braking",
["StatRecording"] = "Recording",
["StatComplete"] = "complete",
["StatIncomplete"] = "being written or cut short",
["Minutes"] = "{0:+0.0;−0.0;0} min",
["OnTime"] = "on time",
["DelayNow"] = "delay {0}",
["DelayStanding"] = "{0} standing at {1}",
["DelayAfter"] = "{0} after {1}",
["TimetableNone"] = "The user's train had no timetable.",
["TtArrived"] = "arr. {0}",
["TtDeparted"] = "dep. {0}",
["TtPassed"] = "passed {0}",
["TtMissed"] = "stop missed",
["TtLeftEarly"] = "left early",
["TtStopShort"] = "{0:0.0} m short",
["TtStopPast"] = "{0:0.0} m past",
["TtStopExact"] = "on the spot",
["TtOnPlatform"] = "along the platform",
["TtOffPlatform"] = "the tail {0:0.0} m off the platform",
["TtTechnical"] = "technical stop",
["TtStanding"] = "standing",
["EventStopped"] = "Stop at {0}, {1}",
["EventStationPassed"] = "Passed {0}",
["EventStationMissed"] = "STOP MISSED: {0}",
["EventReady"] = "{0}: time to leave",
["EventHornSounded"] = "W6: horn sounded",
["EventHornMissed"] = "W6: NO HORN",
["EventCab"] = "Cab {0} of {1}",
["EventSecurityBraking"] = "BRAKING BY {0}",
["EventTimetable"] = "Timetable {0}, {1}",
["CabSpeed"] = "Speed",
["CabAiTarget"] = "AI {0:0} km/h",
["CabAcceleration"] = "Along",
["CabLateral"] = "Lateral",
["CabEndFront"] = "A",
["CabEndRear"] = "B",
["CabDriverUser"] = "user drives",
["CabDriverAi"] = "autopilot",
["CabOutside"] = "outside the cab",
["CabLightsHead"] = "head",
["CabLightsTail"] = "tail",
["CabSectionBrakes"] = "Brakes",
["CabSectionDrive"] = "Traction",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "CA/SHP braking",
["LegendStopPoint"] = "W4 stop point",
["LegendShortStop"] = "W32 stop for shorter trains",
["ShortStopHover"] = "W32 {0}",
["LegendHorn"] = "W6 horn point",
["BandVigilance"] = "CA",
["BandCabSignal"] = "SHP",
["StopPointHover"] = "W4 {0}",
["PlatformHover"] = "platform {0:0} m",
["ShortTrainsHover"] = "for trains up to {0:0} m",
["StopHeadHover"] = "the head to stop {0:0} m before it",
["StopMiddleHover"] = "the middle of the train to stop {0:0} m before it",
["HornHover"] = "W6 {0} · {1:0} s",
["ZoomIn"] = "Zoom in",
["ZoomOut"] = "Zoom out",
["EventCamera"] = "Camera: {0}",
["CameraCab"] = "in the cab",
["CameraExternal"] = "outside view",
["CameraFree"] = "free camera",
["CameraDebug"] = "debug camera",
["EventCoupled"] = "Coupled {0} to {1}",
["EventUncoupled"] = "Uncoupled {0} from {1}",
["EventCouplingChanged"] = "{0} – {1}: {2}",
["CouplingCoupler"] = "coupler",
["CouplingBrakePipe"] = "brake pipe",
["CouplingControl"] = "control",
["CouplingPower"] = "high voltage",
["CouplingGangway"] = "gangway",
["CouplingMainPipe"] = "main pipe",
["CouplingHeating"] = "heating",
["CouplingNothing"] = "nothing",
["BandCamera"] = "camera",
["LegendCamera"] = "camera",
["Events"] = "Events",
["OnlyUser"] = "only the user's",
["FileFilter"] = "Black box"
};
private static readonly Dictionary<string, string> Polish = new()
{
["Title"] = "Czarna skrzynka",
["TitleOf"] = "Czarna skrzynka – {0}",
["Open"] = "Otwórz…",
["Reload"] = "Odśwież",
["Live"] = "Śledź plik",
["LiveTip"] = "Co 2 sekundy doczytuje to, co symulator dopisał do pliku",
["Play"] = "Odtwarzaj",
["Pause"] = "Pauza",
["Follow"] = "Śledź pociąg",
["Fit"] = "Cała mapa",
["NoFile"] = "Otwórz czarną skrzynkę: physicslog/*.blackbox, zapisywaną razem z logami fizyki.",
["Unreadable"] = "Nie można odczytać {0}: {1}",
["Reading"] = "Czytanie {0}…",
["Summary"] = "{0} · {1:0.0} km · maks. {2:0} km/h · jazda {3} · autopilot {4} · ponad AI {5} (w tym {6} o więcej niż {7:0} km/h, najwyżej +{8:0} km/h) · sygnalizatory {9} · minięte na „Stój” {10} · komendy {11}",
["NotComplete"] = "wciąż zapisywana albo ucięta",
["Cursor"] = "{0} · {1:0} km/h · AI {2} · ograniczenie {3}",
["CursorNoAi"] = "{0} · {1:0} km/h · brak danych AI",
["CursorOut"] = "{0} · poza pojazdem",
["None"] = "–",
["LimitAt"] = "{0:0} km/h za {1:0} m",
["EventCommand"] = "{0}",
["EventAutopilotOn"] = "Autopilot włączony",
["EventAutopilotOff"] = "Autopilot wyłączony",
["EventPassed"] = "Sygnalizator {0}: {1}, {2:0} km/h",
["EventDanger"] = "MINIĘTY NA „STÓJ” {0}, {1:0} km/h, {2:0} m za nim",
["EventInVehicle"] = "W {0}",
["EventOutside"] = "Poza kabiną {0}",
["EventNoVehicle"] = "Poza pojazdami",
["EventEnd"] = "Koniec nagrania",
["AspectStop"] = "stój",
["AspectFree"] = "wolna droga",
["AspectSpeed"] = "{0:0}/{1}",
["LegendWithin"] = "w granicach prędkości AI",
["LegendSlightly"] = "do {0:0} km/h ponad",
["LegendOver"] = "więcej ponad",
["LegendAutopilot"] = "autopilot",
["LegendNoData"] = "brak danych AI",
["TimelineSpeed"] = "prędkość",
["TimelineTarget"] = "AI",
["TimelineLimit"] = "następne ograniczenie",
["BandDriving"] = "jazda",
["BandBrakes"] = "hamulce",
["BandAlerter"] = "czuwak",
["BandController"] = "nastawnik",
["Cab"] = "Kabina",
["CabNoData"] = "Użytkownik nie był wtedy w żadnym pojeździe.",
["CabController"] = "Nastawnik",
["CabShunt"] = "Bocznik",
["CabReverser"] = "Kierunek",
["CabForward"] = "naprzód",
["CabBackward"] = "wstecz",
["CabNeutral"] = "0",
["CabTrainBrake"] = "Hamulec zespolony",
["CabLocalBrake"] = "Hamulec pomocniczy",
["CabReservoir"] = "Zbiornik główny",
["CabCylinder"] = "Cylindry",
["CabPipe"] = "Przewód główny",
["CabCurrent"] = "Prąd",
["CabVoltage"] = "Napięcie",
["CabRpm"] = "Obroty",
["CabLights"] = "Światła",
["CabLightsFront"] = "przód",
["CabLightsRear"] = "tył",
["DevBattery"] = "Baterie",
["DevMainBreaker"] = "WS",
["DevPantographFront"] = "Pantograf przedni",
["DevPantographRear"] = "Pantograf tylny",
["DevConverter"] = "Przetwornica",
["DevCompressor"] = "Sprężarka",
["DevLineContactors"] = "Styczniki liniowe",
["DevHeating"] = "Ogrzewanie",
["DevRadio"] = "Radiotelefon",
["DevShuntMode"] = "Jazda manewrowa",
["DevDoorsLeft"] = "Drzwi lewe",
["DevDoorsRight"] = "Drzwi prawe",
["DevSanding"] = "Piasecznica",
["DevHorn"] = "Syrena",
["DevSpringBrake"] = "Hamulec sprężynowy",
["DevAlerter"] = "Czuwak",
["DevWheelSlip"] = "Poślizg",
["DevOverloadRelay"] = "Nadmiarowy",
["DevRadioStop"] = "Radiostop",
["DevAlarmChain"] = "Hamulec bezpieczeństwa",
["TabEvents"] = "Zdarzenia",
["TabTimetable"] = "Rozkład",
["TabSummary"] = "Podsumowanie",
["PanelMap"] = "Mapa",
["PanelTimeline"] = "Przebieg jazdy",
["Relation"] = "{0} → {1}",
["StatDistance"] = "Dystans",
["StatMaxSpeed"] = "Prędkość maks.",
["StatDriven"] = "Jazda użytkownika",
["StatAutopilotSub"] = "autopilot {0}",
["StatOverAi"] = "Ponad AI",
["StatOverAiSub"] = "{0} o >{1:0} km/h · do +{2:0}",
["StatSignals"] = "Sygnalizatory",
["StatSignalsSub"] = "{0} na „Stój”",
["StatStops"] = "Postoje",
["StatStopsSub"] = "{0} pominięte",
["StatDelay"] = "Największe opóźnienie",
["StatDelaySub"] = "przy odjeździe: najwyżej {0}",
["StatPlatform"] = "Postoje poza peronem",
["StatLights"] = "Sygnały czoła i końca",
["StatLightsRight"] = "zgodne",
["StatLightsSub"] = "jak trzeba przez całe {0} jazdy",
["StatLightsHead"] = "czoło {0} zamiast {1}",
["StatLightsTail"] = "koniec {0} zamiast {1}",
["StatPlatformAt"] = "{0} {1:0.0} m",
["StatPlatformNone"] = "na każdym postoju cały skład przy peronie",
["StatHorn"] = "Trąbienie na W6",
["StatHornSub"] = "{0} bez trąbienia",
["StatAcceleration"] = "Przyspieszenie",
["StatAccelerationSub"] = "boczne do {0:0.00} m/s²",
["StatSecurity"] = "Hamowania CA/SHP",
["StatRecording"] = "Nagranie",
["StatComplete"] = "pełne",
["StatIncomplete"] = "w trakcie zapisu lub ucięte",
["Minutes"] = "{0:+0.0;−0.0;0} min",
["OnTime"] = "planowo",
["DelayNow"] = "opóźnienie {0}",
["DelayStanding"] = "{0} stoi w: {1}",
["DelayAfter"] = "{0} za: {1}",
["TimetableNone"] = "Pociąg użytkownika nie miał rozkładu.",
["TtArrived"] = "przyj. {0}",
["TtDeparted"] = "odj. {0}",
["TtPassed"] = "przejazd {0}",
["TtMissed"] = "pominięty postój",
["TtLeftEarly"] = "odjazd przed czasem",
["TtStopShort"] = "{0:0.0} m przed miejscem",
["TtStopPast"] = "{0:0.0} m za miejscem",
["TtStopExact"] = "w punkt",
["TtOnPlatform"] = "cały przy peronie",
["TtOffPlatform"] = "koniec składu {0:0.0} m poza peronem",
["TtTechnical"] = "postój techniczny",
["TtStanding"] = "stoi",
["EventStopped"] = "Postój: {0}, {1}",
["EventStationPassed"] = "Przejazd: {0}",
["EventStationMissed"] = "POMINIĘTY POSTÓJ: {0}",
["EventReady"] = "{0}: czas odjazdu",
["EventHornSounded"] = "W6: zatrąbił",
["EventHornMissed"] = "W6: BEZ TRĄBIENIA",
["EventCab"] = "Kabina {0} w {1}",
["EventSecurityBraking"] = "HAMOWANIE PRZEZ {0}",
["EventTimetable"] = "Rozkład {0}, {1}",
["CabSpeed"] = "Prędkość",
["CabAiTarget"] = "AI {0:0} km/h",
["CabAcceleration"] = "Wzdłużne",
["CabLateral"] = "Boczne",
["CabEndFront"] = "A",
["CabEndRear"] = "B",
["CabDriverUser"] = "prowadzi użytkownik",
["CabDriverAi"] = "autopilot",
["CabOutside"] = "poza kabiną",
["CabLightsHead"] = "czoło",
["CabLightsTail"] = "koniec",
["CabSectionBrakes"] = "Hamulce",
["CabSectionDrive"] = "Napęd",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "Hamowanie CA/SHP",
["LegendStopPoint"] = "W4 – wskaźnik zatrzymania",
["LegendShortStop"] = "W32 – zatrzymanie krótszych pociągów",
["ShortStopHover"] = "W32 {0}",
["LegendHorn"] = "W6 – „trąbić”",
["BandVigilance"] = "CA",
["BandCabSignal"] = "SHP",
["StopPointHover"] = "W4 {0}",
["PlatformHover"] = "peron {0:0} m",
["ShortTrainsHover"] = "dla pociągów do {0:0} m",
["StopHeadHover"] = "czoło ma stanąć {0:0} m przed W4",
["StopMiddleHover"] = "środek składu ma stanąć {0:0} m przed W4",
["HornHover"] = "W6 {0} · {1:0} s",
["ZoomIn"] = "Przybliż",
["ZoomOut"] = "Oddal",
["EventCamera"] = "Kamera: {0}",
["CameraCab"] = "w kabinie",
["CameraExternal"] = "widok zewnętrzny",
["CameraFree"] = "wolna kamera",
["CameraDebug"] = "kamera debugowania",
["EventCoupled"] = "Sprzęgnięto {0} z {1}",
["EventUncoupled"] = "Rozprzęgnięto {0} od {1}",
["EventCouplingChanged"] = "{0} – {1}: {2}",
["CouplingCoupler"] = "sprzęg",
["CouplingBrakePipe"] = "przewód hamulcowy",
["CouplingControl"] = "sterowanie",
["CouplingPower"] = "WN",
["CouplingGangway"] = "mostek",
["CouplingMainPipe"] = "przewód zasilający",
["CouplingHeating"] = "ogrzewanie",
["CouplingNothing"] = "nic",
["BandCamera"] = "kamera",
["LegendCamera"] = "kamera",
["Events"] = "Zdarzenia",
["OnlyUser"] = "tylko użytkownika",
["FileFilter"] = "Czarna skrzynka"
};
private readonly Dictionary<string, string> _texts;
public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
{
}
public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
{
}
/// <summary>Polish for "pl", English for any other language.</summary>
public DefaultBlackBoxStrings(string languageCode)
{
_texts = languageCode == "pl" ? Polish : English;
}
/// <summary>The keys there are texts for, for a translation to give them all.</summary>
public static IEnumerable<string> Keys => English.Keys;
public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
}

View File

@@ -0,0 +1,569 @@
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;
/// <summary>
/// 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.
/// </summary>
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<string>();
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);
}
/// <summary>The button for opening another file: the starter opens the one picked in its list.</summary>
public bool CanOpenFiles
{
get => _open.IsVisible;
set => _open.IsVisible = value;
}
/// <summary>Reads what the simulator adds to the file every two seconds.</summary>
public bool Live
{
get => _live.IsChecked == true;
set => _live.IsChecked = value;
}
public string? FilePath => _path;
public Recording? Recording => _reader?.Recording;
public double CurrentTime => _time;
/// <summary>Goes to a moment of the recording, in seconds from its start.</summary>
public void SeekTo(double t) => SetTime(t);
/// <summary>Keeps the user's train in the middle of the map.</summary>
public bool FollowTrain
{
get => _follow.IsChecked == true;
set => _follow.IsChecked = value;
}
/// <summary>The timetable beside the map instead of the events.</summary>
public bool ShowTimetable
{
get => _tabs.Selected == 1;
set => _tabs.Selected = value ? 1 : 0;
}
/// <summary>The summary of the driving beside the map instead of the events.</summary>
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();
}
/// <summary>Opens a black box, read whole on another thread.</summary>
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 = "";
}
/// <summary>What the simulator added since the last read. Small, so read here, between frames.</summary>
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);
}
/// <summary>How late the user's train was at the moment played, by the clock.</summary>
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)));
}
/// <summary>The user's train at the moment under the pointer of the timeline, or at the moment played.</summary>
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);
}
}

View File

@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The cab of the user's train at the moment played: the speed against the AI's, the
/// accelerations, the controllers, the pressures of the brakes as gauges, the traction,
/// the lights at the head and the tail of the train, and a lamp for every device, the
/// alerter (CA) and the cab signalling (SHP) apart.
/// </summary>
public sealed class CabPanel : Control
{
private const double Pad = 14;
private const double LampRow = 16;
private const int LampColumns = 3;
private Recording? _recording;
private double _time;
private int _measured = -1;
private int _controllerTop = 1;
public CabPanel()
{
ClipToBounds = true;
Height = 428;
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_measured = -1;
InvalidateVisual();
}
}
public double Time
{
get => _time;
set
{
_time = value;
InvalidateVisual();
}
}
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
double width = size.Width - 2 * Pad;
// The last sample, unless the train was out of sight for longer than a moment.
if (_recording is not { } recording || recording.CabAt(_time) is not { } s || _time - s.Time > 1.5)
{
context.DrawText(MapView.Text(Strings["CabNoData"], Palette.DimText, 12), new Point(Pad, 40));
return;
}
Measure(recording);
var culture = CultureInfo.CurrentCulture;
// Who drives, from which cab of which vehicle.
double chipX = size.Width - Pad;
string name = recording.Vehicle(s.Vehicle)?.Name ?? "";
if (s.CabEnd != 0)
chipX -= ChipLeft(context, Strings[s.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], Palette.Text, chipX, 10) + 5;
var (driverText, driverBrush) = !s.InCab ? (Strings["CabOutside"], Palette.DimText)
: s.Driving == Driver.Ai ? (Strings["CabDriverAi"], Palette.Autopilot)
: (Strings["CabDriverUser"], Palette.UserTrain);
chipX -= ChipLeft(context, driverText, driverBrush, chipX, 10) + 8;
var vehicle = MapView.Text(name, Palette.FaintText, 11);
if (chipX - vehicle.Width > Pad + 70)
context.DrawText(vehicle, new Point(chipX - vehicle.Width, 13));
// The speed, large, and what the AI would do.
double top = 38;
var judgement = DrivingSummary.Judge(s);
var speed = MapView.Text(s.AbsSpeedKmh.ToString("0", culture), judgement is SpeedJudgement.Over ? Palette.Bad
: judgement is SpeedJudgement.Slightly ? Palette.Warn : Palette.Text, 38, FontWeight.SemiBold);
context.DrawText(speed, new Point(Pad, top - 4));
context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, top + speed.Height - 24));
if (s.AiValid)
{
context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12),
new Point(Pad, top + speed.Height - 4));
if (s.Ai.HasNextLimit)
context.DrawText(MapView.Text(string.Format(culture, Strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance), Palette.Limit, 11),
new Point(Pad, top + speed.Height + 12));
}
// The accelerations, as needles from the middle: along the track and across it.
double gaugeX = Pad + Math.Max(150, width * 0.42);
double gaugeWidth = size.Width - Pad - gaugeX;
Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, top + 2, gaugeWidth);
Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, top + 34, gaugeWidth);
// The controllers.
top = 112;
Divider(context, top - 8, size.Width);
double column = width / 4;
Value(context, Strings["CabController"], s.MainController.ToString(culture), Pad, top);
Steps(context, s.MainController, _controllerTop, Pad, top + 34, column - 14);
Value(context, Strings["CabShunt"], s.ScndController.ToString(culture), Pad + column, top);
Value(context, Strings["CabReverser"], Strings[s.Reverser > 0 ? "CabForward" : s.Reverser < 0 ? "CabBackward" : "CabNeutral"], Pad + 2 * column, top);
Value(context, Strings["CabTrainBrake"], s.TrainBrake.ToString("0.0", culture), Pad + 3 * column, top);
// The brakes, as gauges.
top = 160;
Divider(context, top - 6, size.Width);
Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, top + 18, width);
Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, top + 36, width);
Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, top + 54, width);
// The traction and the lights at both ends of the train.
top = 242;
Divider(context, top - 6, size.Width);
double third = width * 0.62 / 3;
Value(context, Strings["CabVoltage"], $"{s.Voltage:0} V", Pad, top);
Value(context, Strings["CabCurrent"], $"{s.MotorCurrent:0} A", Pad + third, top);
Value(context, Strings["CabRpm"], $"{s.EngineRpm:0}", Pad + 2 * third, top);
double lightsX = Pad + 3 * third + 4;
context.DrawText(MapView.Text(Strings["CabLights"], Palette.DimText, 10), new Point(lightsX, top));
double faceX = lightsX;
foreach (var (key, lights, required) in new[]
{
("CabLightsHead", s.LightsHead, HeadCodes.RequiredHead(s)),
("CabLightsTail", s.LightsTail, HeadCodes.RequiredTail(s))
})
{
DrawFace(context, lights, new Point(faceX + 18, top + 24));
// the end, and the signal it shows: green as it is to be, red otherwise with the one it is to show
var shown = HeadCodes.Of(lights);
var text = required is { } want && want != shown
? $"{HeadCodes.Name(shown)} ≠ {HeadCodes.Name(want)}"
: HeadCodes.Name(shown);
var brush = required is null ? Palette.FaintText : required == shown ? Palette.Good : Palette.Bad;
var label = MapView.Text($"{Strings[key]} {text}", brush, 9);
context.DrawText(label, new Point(faceX + 18 - label.Width / 2, top + 34));
faceX += 62;
}
// The lamps.
top = 292;
Divider(context, top - 6, size.Width);
int i = 0;
double lampColumn = width / LampColumns;
foreach (var (key, brush) in Lamps(s))
{
var at = new Point(Pad + i % LampColumns * lampColumn, top + i / LampColumns * LampRow);
Led(context, at + new Vector(5, 7), brush);
context.DrawText(MapView.Text(Strings[key], brush is null ? Palette.FaintText : Palette.Text, 10.5), at + new Vector(14, 0));
i++;
}
}
/// <summary>The highest step of the controller used in the recording, for its bar.</summary>
private void Measure(Recording recording)
{
if (_measured == recording.Revision)
return;
int top = 1;
foreach (var sample in recording.Cab)
top = Math.Max(top, sample.MainController);
_controllerTop = top;
_measured = recording.Revision;
}
private static void Divider(DrawingContext context, double y, double width) =>
context.DrawLine(new Pen(Palette.Grid, 1), new Point(Pad, y + 0.5), new Point(width - Pad, y + 0.5));
private static void Value(DrawingContext context, string label, string value, double x, double y)
{
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
context.DrawText(MapView.Text(value, Palette.Text, 14, FontWeight.SemiBold), new Point(x, y + 13));
}
/// <summary>The steps of the controller as a row of cells, lit up to the one it stands at.</summary>
private static void Steps(DrawingContext context, int step, int top, double x, double y, double width)
{
int cells = Math.Clamp(top, 1, 40);
double cell = width / cells;
for (int i = 0; i < cells; i++)
context.DrawRectangle(i < step ? Palette.Controller : Palette.LampOff, null,
new Rect(x + i * cell, y, Math.Max(1, cell - (cells > 20 ? 0.5 : 1.5)), 4), 1, 1);
}
/// <summary>A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.</summary>
private static void Gauge(DrawingContext context, string label, float value, double scale, double? mark, string text,
IBrush brush, double x, double y, double width)
{
const double labelWidth = 118, valueWidth = 64;
context.DrawText(MapView.Text(label, Palette.DimText, 11), new Point(x, y));
double barX = x + labelWidth, barWidth = width - labelWidth - valueWidth;
var track = new Rect(barX, y + 5, barWidth, 6);
context.DrawRectangle(Palette.LampOff, null, track, 3, 3);
double filled = Math.Clamp(value / scale, 0, 1) * barWidth;
if (filled > 0.5)
context.DrawRectangle(brush, null, new Rect(barX, y + 5, filled, 6), 3, 3);
if (mark is { } m)
{
double mx = barX + m / scale * barWidth;
context.DrawLine(new Pen(Palette.Text, 1.5), new Point(mx, y + 2), new Point(mx, y + 14));
}
var v = MapView.Text(text, Palette.Text, 11, FontWeight.SemiBold);
context.DrawText(v, new Point(x + width - v.Width, y));
}
/// <summary>A value either way of nought: a bar from the middle, to the right above it.</summary>
private static void Balance(DrawingContext context, string label, float value, double scale, double x, double y, double width)
{
var culture = CultureInfo.CurrentCulture;
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
var v = MapView.Text($"{value.ToString("+0.00;−0.00;0.00", culture)} m/s²", Palette.Text, 11, FontWeight.SemiBold);
context.DrawText(v, new Point(x + width - v.Width, y));
double barY = y + 17, middle = x + width / 2;
context.DrawRectangle(Palette.LampOff, null, new Rect(x, barY, width, 5), 2.5, 2.5);
double length = Math.Clamp(value / scale, -1, 1) * width / 2;
var brush = Math.Abs(value) > scale * 0.7 ? Palette.Bad : value >= 0 ? Palette.Good : Palette.Brakes;
if (Math.Abs(length) > 0.5)
context.DrawRectangle(brush, null, new Rect(Math.Min(middle, middle + length), barY, Math.Abs(length), 5), 2.5, 2.5);
context.DrawLine(new Pen(Palette.DimText, 1), new Point(middle, barY - 2), new Point(middle, barY + 7));
}
/// <summary>A chip ending at x, its width returned.</summary>
private static double ChipLeft(DrawingContext context, string text, IBrush brush, double right, double y)
{
var label = MapView.Text(text, brush, 11, FontWeight.SemiBold);
var box = new Rect(right - label.Width - 12, y, label.Width + 12, label.Height + 4);
context.DrawRectangle(Palette.Faded(brush, 0.14), null, box, 4, 4);
context.DrawText(label, new Point(box.X + 6, y + 2));
return box.Width;
}
/// <summary>The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.</summary>
private static void DrawFace(DrawingContext context, Lights lights, Point center)
{
context.DrawRectangle(null, new Pen(Palette.Grid, 1), new Rect(center.X - 17, center.Y - 15, 34, 22), 5, 5);
bool rearEnd = lights.HasFlag(Lights.RearEndSignals);
Lamp(context, center + new Vector(0, -8), lights.HasFlag(Lights.HeadlightUpper), false, false);
Lamp(context, center + new Vector(-10, 1), lights.HasFlag(Lights.HeadlightLeft) || lights.HasFlag(Lights.AuxiliaryLeft),
lights.HasFlag(Lights.RedMarkerLeft) || rearEnd, lights.HasFlag(Lights.HighBeamLeft));
Lamp(context, center + new Vector(10, 1), lights.HasFlag(Lights.HeadlightRight) || lights.HasFlag(Lights.AuxiliaryRight),
lights.HasFlag(Lights.RedMarkerRight) || rearEnd, lights.HasFlag(Lights.HighBeamRight));
}
private static void Lamp(DrawingContext context, Point center, bool white, bool red, bool high)
{
var fill = white ? Palette.LightWhite : red ? Palette.LightRed : Palette.LampOff;
double radius = high ? 4.5 : 3.5;
if (white || red)
context.DrawEllipse(Palette.Faded(fill, 0.25), null, center, radius + 3, radius + 3);
context.DrawEllipse(fill, null, center, radius, radius);
}
private static void Led(DrawingContext context, Point center, IBrush? lit)
{
if (lit is not null)
context.DrawEllipse(Palette.Faded(lit, 0.3), null, center, 6, 6);
context.DrawEllipse(lit ?? Palette.LampOff, null, center, 3.5, 3.5);
}
/// <summary>Every device, with the colour of its lamp if it was on.</summary>
private static IEnumerable<(string Key, IBrush? Lit)> Lamps(CabSample s)
{
IBrush? On(CabDevices device, IBrush colour) => s.Has(device) ? colour : null;
IBrush? Flag(CabFlags flag, IBrush colour) => (s.Flags & flag) != 0 ? colour : null;
IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
yield return ("DevBattery", On(CabDevices.Battery, Palette.LampOn));
yield return ("DevMainBreaker", On(CabDevices.MainBreaker, Palette.LampOn));
yield return ("DevConverter", On(CabDevices.Converter, Palette.LampOn));
yield return ("DevPantographFront", On(CabDevices.PantographFront, Palette.LampOn));
yield return ("DevPantographRear", On(CabDevices.PantographRear, Palette.LampOn));
yield return ("DevCompressor", On(CabDevices.Compressor, Palette.LampOn));
yield return ("DevLineContactors", On(CabDevices.LineContactors, Palette.LampOn));
yield return ("DevHeating", On(CabDevices.Heating, Palette.LampOn));
yield return ("DevRadio", On(CabDevices.Radio, Palette.LampOn));
yield return ("DevShuntMode", On(CabDevices.ShuntMode, Palette.LampOn));
yield return ("DevDoorsLeft", On(CabDevices.DoorsLeft, Palette.LampWarning));
yield return ("DevDoorsRight", On(CabDevices.DoorsRight, Palette.LampWarning));
yield return ("DevSanding", On(CabDevices.Sanding, Palette.LampWarning));
yield return ("DevHorn", On(CabDevices.Horn, Palette.LampWarning));
yield return ("DevSpringBrake", On(CabDevices.SpringBrake, Palette.LampWarning));
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
{
// A recording which tells them apart.
yield return ("DevVigilance", Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance));
yield return ("DevCabSignal", Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal));
yield return ("DevSecurityBraking", Security(SecurityFlags.Braking, Palette.LampAlarm));
}
else
yield return ("DevAlerter", Flag(CabFlags.AlerterBeeping, Palette.LampAlarm) ?? Flag(CabFlags.AlerterBlinking, Palette.LampWarning));
yield return ("DevWheelSlip", Flag(CabFlags.WheelSlip, Palette.LampAlarm));
yield return ("DevOverloadRelay", On(CabDevices.OverloadRelay, Palette.LampAlarm));
yield return ("DevRadioStop", Flag(CabFlags.RadioStop, Palette.LampAlarm));
yield return ("DevAlarmChain", Flag(CabFlags.AlarmChain, Palette.LampAlarm));
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The window that replays a black box: the map, the timeline, the events. Code
only, no XAML loaded at run time, so it goes through NativeAOT and trimming
in the starter and in the viewer alike. -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>Eu07.BlackBox.Views</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>One line of the list of events.</summary>
public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
/// <summary>
/// What happened to the user's train, by the clock: the commands, the autopilot, the
/// signals passed, the stations, the horn points, the couplings, the train protection,
/// the cab and the camera of the user. A click goes to its moment.
/// </summary>
public sealed class EventList : ListBox
{
private Recording? _recording;
private int _shownRevision = -1;
private List<BlackBoxEvent> _events = new();
private bool _picking;
public event Action<double>? Seek;
// The look of a list box: a control of a type of its own has no theme otherwise, and shows nothing.
protected override Type StyleKeyOverride => typeof(ListBox);
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
public EventList()
{
SelectionMode = SelectionMode.Single;
Padding = new Thickness(4, 0, 4, 6);
// The picked line in the starter's green.
Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
SelectionChanged += (_, _) =>
{
if (!_picking && SelectedIndex >= 0 && SelectedIndex < _events.Count)
Seek?.Invoke(_events[SelectedIndex].Time);
};
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_shownRevision = -1;
Refresh();
}
}
public IReadOnlyList<BlackBoxEvent> Events => _events;
/// <summary>Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.</summary>
public void Refresh()
{
if (_recording is not { } recording)
{
_events.Clear();
Items.Clear();
return;
}
if (recording.Revision == _shownRevision)
return;
_shownRevision = recording.Revision;
var events = Collect(recording, Strings);
bool grown = events.Count >= _events.Count &&
_events.Select((e, i) => e == events[i]).All(same => same);
if (!grown)
Items.Clear();
for (int i = grown ? _events.Count : 0; i < events.Count; i++)
Items.Add(new ListBoxItem
{
Content = Line(recording, events[i]),
Padding = new Thickness(8, 4),
MinHeight = 0,
CornerRadius = new CornerRadius(6),
Margin = new Thickness(0, 1)
});
_events = events;
}
/// <summary>The event at or just before t picked, without going to it.</summary>
public void ShowTime(double t)
{
int index = -1;
for (int i = 0; i < _events.Count && _events[i].Time <= t + 0.05; i++)
index = i;
if (index == SelectedIndex)
return;
_picking = true;
try
{
SelectedIndex = index;
if (index >= 0)
ScrollIntoView(index);
}
finally
{
_picking = false;
}
}
private static Control Line(Recording recording, BlackBoxEvent e)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("58,14,*") };
var time = new TextBlock
{
Text = recording.ClockAt(e.Time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
Foreground = Palette.FaintText,
FontSize = 11.5,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 1, 0, 0)
};
var dot = new Border
{
Width = e.Important ? 8 : 6,
Height = e.Important ? 8 : 6,
CornerRadius = new CornerRadius(4),
Background = e.Brush,
VerticalAlignment = VerticalAlignment.Top,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, e.Important ? 5 : 6, 0, 0)
};
var text = new TextBlock
{
Text = e.Text,
Foreground = e.Important ? Palette.Text : Palette.DimText,
FontSize = 12.5,
FontWeight = e.Important ? FontWeight.SemiBold : FontWeight.Normal,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(dot, 1);
Grid.SetColumn(text, 2);
grid.Children.Add(time);
grid.Children.Add(dot);
grid.Children.Add(text);
return grid;
}
/// <summary>The events of the user's train, in the order they happened.</summary>
public static List<BlackBoxEvent> Collect(Recording recording, IBlackBoxStrings strings)
{
var culture = CultureInfo.CurrentCulture;
var events = new List<BlackBoxEvent>();
foreach (var c in recording.UserCommands())
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCommand"], c.Name), Palette.Command, false));
foreach (var d in recording.DriverChanges)
{
if (recording.CabAt(d.Time + 0.5) is not { } s || s.Vehicle != d.Vehicle)
continue;
events.Add(new BlackBoxEvent(d.Time, strings[d.Driving == Driver.Ai ? "EventAutopilotOn" : "EventAutopilotOff"],
Palette.Autopilot, true));
}
foreach (var p in recording.UserPassings())
{
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
events.Add(p.AtDanger
? new BlackBoxEvent(p.Time, string.Format(culture, strings["EventDanger"], name, Math.Abs(p.SpeedKmh), p.Overrun), Palette.Over, true)
: new BlackBoxEvent(p.Time, string.Format(culture, strings["EventPassed"], name, MapView.AspectText(strings, p.Speed1, p.Speed2),
Math.Abs(p.SpeedKmh)), Palette.Of(new AspectChange(p.Time, p.Signal, p.Speed1, p.Speed2, "")), false));
}
AddUserVehicles(recording, strings, events, culture);
AddStations(recording, strings, events, culture);
foreach (var horn in recording.UserHornChecks())
events.Add(new BlackBoxEvent(horn.Request.Time, strings[horn.Sounded ? "EventHornSounded" : "EventHornMissed"],
horn.Sounded ? Palette.HornPoint : Palette.Bad, !horn.Sounded));
foreach (var span in recording.SecurityBrakings())
{
// Which of them was not acknowledged: the lamps blinking when it braked.
string who = recording.CabAt(span.Start) is { } s
? s.Has(SecurityFlags.CabSignal) && s.Has(SecurityFlags.Vigilance) ? "CA/SHP" : s.Has(SecurityFlags.CabSignal) ? "SHP" : "CA"
: "CA";
events.Add(new BlackBoxEvent(span.Start, string.Format(culture, strings["EventSecurityBraking"], who), Palette.Bad, true));
}
AddCameras(recording, strings, events, culture);
AddCouplings(recording, strings, events, culture);
foreach (var t in recording.Timetables)
events.Add(new BlackBoxEvent(t.Time, string.Format(culture, strings["EventTimetable"], $"{t.Category} {t.Train}".Trim(),
string.Format(culture, strings["Relation"], t.From, t.To)), Palette.Station, false));
if (recording.EndTime is { } end)
events.Add(new BlackBoxEvent(end, strings["EventEnd"], Palette.FaintText, false));
// Stable: what happened at the same moment stays in the order of its kind.
return events.OrderBy(e => e.Time).ToList();
}
private static void AddUserVehicles(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
UserVehicleChange? before = null;
foreach (var u in recording.UserVehicles)
{
string vehicle = recording.Vehicle(u.Vehicle)?.Name ?? "";
// Only the cab changed: told as such.
if (before is { } b && b.Vehicle == u.Vehicle && b.InCab == u.InCab && b.CabEnd != u.CabEnd && u.CabEnd != 0)
events.Add(new BlackBoxEvent(u.Time, string.Format(culture, strings["EventCab"], strings[u.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], vehicle),
Palette.UserTrain, true));
else
{
string text = u.Vehicle == 0
? strings["EventNoVehicle"]
: string.Format(culture, strings[u.InCab ? "EventInVehicle" : "EventOutside"], vehicle);
events.Add(new BlackBoxEvent(u.Time, text, u.InCab ? Palette.UserTrain : Palette.DimText, true));
}
before = u;
}
}
private static void AddStations(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
foreach (var st in recording.UserStations())
{
switch (st.Kind)
{
case StationKind.Stopped:
{
float error = st.StopError;
string where = Math.Abs(error) < 1
? strings["TtStopExact"]
: string.Format(culture, strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error));
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStopped"], StationNames.Display(st.Station), where), Palette.Station, true));
break;
}
case StationKind.Passed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationPassed"], StationNames.Display(st.Station)), Palette.Station, false));
break;
case StationKind.Missed:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationMissed"], StationNames.Display(st.Station)), Palette.Bad, true));
break;
case StationKind.Ready:
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventReady"], StationNames.Display(st.Station)), Palette.Station, false));
break;
}
}
}
/// <summary>The camera of the user going to another mode: the cab, outside, free.</summary>
private static void AddCameras(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
CameraMode? mode = null;
foreach (var c in recording.Cameras)
{
if (c.Mode == mode)
continue;
if (mode is not null)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCamera"], strings[CameraKey(c.Mode)]), Palette.DimText, false));
mode = c.Mode;
}
}
public static string CameraKey(CameraMode mode) => mode switch
{
CameraMode.Cab => "CameraCab",
CameraMode.External => "CameraExternal",
CameraMode.Debug => "CameraDebug",
_ => "CameraFree"
};
private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
{
string Name(int id) => recording.Vehicle(id)?.Name ?? id.ToString(culture);
foreach (var c in recording.UserCouplings())
{
if (c.Coupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCoupled"], Name(c.Vehicle), Name(c.Other)), Palette.Good, true));
else if (c.Uncoupled)
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventUncoupled"], Name(c.Vehicle), Name(c.PreviousOther)), Palette.Warn, true));
else
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCouplingChanged"], Name(c.Vehicle), Name(c.Other),
CouplingText(strings, c.Flags)), Palette.DimText, false));
}
}
/// <summary>What a coupling joins, in words.</summary>
public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
{
var parts = new List<string>();
foreach (var (flag, key) in new[]
{
(CouplingFlags.Coupler, "CouplingCoupler"), (CouplingFlags.BrakePipe, "CouplingBrakePipe"),
(CouplingFlags.MainPipe, "CouplingMainPipe"), (CouplingFlags.Control, "CouplingControl"),
(CouplingFlags.Power, "CouplingPower"), (CouplingFlags.Heating, "CouplingHeating"),
(CouplingFlags.Gangway, "CouplingGangway")
})
if ((flags & flag) != 0)
parts.Add(strings[key]);
return parts.Count == 0 ? strings["CouplingNothing"] : string.Join(", ", parts);
}
}

View File

@@ -0,0 +1,756 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The map of the scenery at a moment of the recording: the rail tracks with the switches
/// as they lay, the signals in the colour of what they showed, every vehicle where it was,
/// and the way the user's train went, coloured by how its speed stood against the AI's.
/// North is up, as on the map in the simulator. Dragged to move, the wheel zooms, a
/// double click shows it all.
/// </summary>
public sealed class MapView : Control
{
private const double MinScale = 0.002;
private const double MaxScale = 40;
private const double FollowScale = 1.2;
private Recording? _recording;
private int _builtRevision = -1;
private int _builtTracks = -1;
private StreamGeometry? _tracks;
private readonly List<(int Index, int Branch, StreamGeometry Way)> _switchWays = new();
private readonly StreamGeometry?[] _trail = new StreamGeometry?[5];
private Rect _world;
private double _centerX, _centerZ;
private double _scale = 0.2; // pixels per metre
private bool _fitted;
private Point? _dragFrom;
private (double X, double Z) _dragCenter;
private Point? _pointer;
private double _time;
private bool _follow;
/// <summary>The user moved the map away from the train: following is off.</summary>
public event Action? FollowStopped;
public MapView()
{
ClipToBounds = true;
Focusable = true;
}
public Recording? Recording
{
get => _recording;
set
{
_recording = value;
_builtRevision = -1;
_builtTracks = -1;
_fitted = false;
InvalidateVisual();
}
}
public double Time
{
get => _time;
set
{
_time = value;
InvalidateVisual();
}
}
/// <summary>Keeps the user's train in the middle.</summary>
public bool Follow
{
get => _follow;
set
{
// Close enough to see the train go, unless already closer.
if (value && !_follow)
_scale = Math.Max(_scale, FollowScale);
_follow = value;
InvalidateVisual();
}
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
/// <summary>Closer by the factor given, about the middle.</summary>
public void ZoomBy(double factor)
{
_scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
InvalidateVisual();
}
/// <summary>The map as a whole in view.</summary>
public void Fit()
{
FitWorld();
InvalidateVisual();
}
// Also from the render, which must not ask for another one.
private void FitWorld()
{
if (_world.Width <= 0 && _world.Height <= 0)
return;
var size = Bounds.Size;
if (size.Width <= 0 || size.Height <= 0)
return;
_centerX = -_world.Center.X;
_centerZ = -_world.Center.Y;
_scale = Math.Clamp(Math.Min(size.Width / Math.Max(_world.Width, 50), size.Height / Math.Max(_world.Height, 50)) * 0.92,
MinScale, MaxScale);
if (_follow)
_scale = Math.Max(_scale, FollowScale);
_fitted = true;
}
// The map's plane as the simulator's map draws it, seen from above: x to the left (the game's x runs the
// other way than a map's east), z up the screen.
private static Point Plane(double x, double z) => new(-x, -z);
private Point ToScreen(double x, double z) =>
new((_centerX - x) * _scale + Bounds.Width / 2, (_centerZ - z) * _scale + Bounds.Height / 2);
private (double X, double Z) ToWorld(Point screen) =>
(_centerX - (screen.X - Bounds.Width / 2) / _scale, _centerZ - (screen.Y - Bounds.Height / 2) / _scale);
private void Build(Recording recording)
{
if (recording.Tracks.Count != _builtTracks)
{
_builtTracks = recording.Tracks.Count;
_switchWays.Clear();
var tracks = new StreamGeometry();
double left = double.MaxValue, top = double.MaxValue, right = double.MinValue, bottom = double.MinValue;
using (var g = tracks.Open())
{
foreach (var track in recording.Tracks)
{
if (track.Points.Count < 2)
continue;
foreach (var p in track.Points)
{
left = Math.Min(left, -p.X);
right = Math.Max(right, -p.X);
top = Math.Min(top, -p.Z);
bottom = Math.Max(bottom, -p.Z);
}
if (track.Kind == TrackKind.SwitchBranch)
{
var way = new StreamGeometry();
using (var w = way.Open())
Polyline(w, track.Points);
_switchWays.Add((track.Index, track.Branch, way));
}
else
Polyline(g, track.Points);
}
}
_tracks = tracks;
_world = left <= right ? new Rect(new Point(left, top), new Point(right, bottom)) : default;
}
BuildTrail(recording);
_builtRevision = recording.Revision;
}
private static void Polyline(StreamGeometryContext g, IReadOnlyList<System.Numerics.Vector3> points)
{
g.BeginFigure(Plane(points[0].X, points[0].Z), false);
for (int i = 1; i < points.Count; i++)
g.LineTo(Plane(points[i].X, points[i].Z));
g.EndFigure(false);
}
/// <summary>The way the user's train went, a geometry for each judgement of its speed.</summary>
private void BuildTrail(Recording recording)
{
var lines = new List<List<Point>>[_trail.Length];
for (int i = 0; i < lines.Length; i++)
lines[i] = new List<List<Point>>();
var cab = recording.Cab;
int open = -1;
for (int i = 1; i < cab.Count; i++)
{
var before = cab[i - 1];
var sample = cab[i];
if (sample.Vehicle != before.Vehicle || sample.Time - before.Time > 1.0)
{
open = -1;
continue;
}
if (before.Position.DistanceTo(sample.Position) < 0.5 && open >= 0)
continue;
int kind = (int)DrivingSummary.Judge(sample);
if (kind != open)
{
lines[kind].Add(new List<Point> { Plane(before.Position.X, before.Position.Z) });
open = kind;
}
lines[kind][^1].Add(Plane(sample.Position.X, sample.Position.Z));
}
for (int kind = 0; kind < _trail.Length; kind++)
{
if (lines[kind].Count == 0)
{
_trail[kind] = null;
continue;
}
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
foreach (var line in lines[kind].Where(l => l.Count > 1))
{
g.BeginFigure(line[0], false);
foreach (var p in line.Skip(1))
g.LineTo(p);
g.EndFigure(false);
}
}
_trail[kind] = geometry;
}
}
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
context.FillRectangle(Palette.MapBackground, new Rect(size));
var recording = _recording;
if (recording is null)
return;
if (recording.Revision != _builtRevision)
Build(recording);
if (!_fitted)
FitWorld();
if (_follow && recording.CabPositionAt(_time) is { } followed)
{
_centerX = followed.X;
_centerZ = followed.Z;
}
var toScreen = Matrix.CreateTranslation(_centerX, _centerZ) * Matrix.CreateScale(_scale, _scale) *
Matrix.CreateTranslation(size.Width / 2, size.Height / 2);
double pixel = 1 / _scale;
using (context.PushTransform(toScreen))
{
// The tracks on a dark casing, so they stand out where they cross and run close.
var casing = new Pen(Palette.TrackCasing, 5 * pixel, lineCap: PenLineCap.Round);
if (_tracks is not null)
context.DrawGeometry(null, casing, _tracks);
foreach (var (_, _, way) in _switchWays)
context.DrawGeometry(null, casing, way);
if (_tracks is not null)
context.DrawGeometry(null, new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round), _tracks);
var off = new Pen(Palette.SwitchOff, 1.7 * pixel, lineCap: PenLineCap.Round);
var on = new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round);
// The ways not taken under those taken.
foreach (var (index, branch, way) in _switchWays)
{
int state = recording.SwitchAt(index, _time);
if (state >= 0 && state != branch)
context.DrawGeometry(null, off, way);
}
foreach (var (index, branch, way) in _switchWays)
{
int state = recording.SwitchAt(index, _time);
if (state < 0 || state == branch)
context.DrawGeometry(null, on, way);
}
// The way the train went, in the colour of the judgement.
for (int kind = 0; kind < _trail.Length; kind++)
if (_trail[kind] is { } trail)
context.DrawGeometry(null, new Pen(Palette.Of((SpeedJudgement)kind), 3.5 * pixel, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), trail);
}
DrawStopPoints(context, recording);
DrawHornPoints(context, recording);
DrawSignals(context, recording);
DrawPassings(context, recording);
DrawVehicles(context, recording);
DrawUserTrain(context, recording);
DrawCamera(context, recording);
DrawScale(context, size);
DrawLegend(context, size);
DrawHover(context, recording);
}
private bool Visible(Point p, double margin = 20) =>
p.X >= -margin && p.Y >= -margin && p.X <= Bounds.Width + margin && p.Y <= Bounds.Height + margin;
private void DrawSignals(DrawingContext context, Recording recording)
{
double radius = _scale > 0.5 ? 4.5 : _scale > 0.1 ? 3.5 : 2.5;
var outline = new Pen(Palette.Background, 1);
foreach (var signal in recording.Signals)
{
var p = ToScreen(signal.Position.X, signal.Position.Z);
if (!Visible(p))
continue;
context.DrawEllipse(Palette.Of(recording.AspectAt(signal.Index, _time)), outline, p, radius, radius);
}
}
/// <summary>
/// The stop points (W4): a white plate, blue where the user's train stopped, red where it
/// missed the stop; the name of the station once close enough to read it.
/// </summary>
// The way the trains a stop point is for go: a wedge ahead of the plate, as a train sees the W4 coming.
private static void DrawStopWay(DrawingContext context, Point plate, float heading, double size, IBrush fill, IPen outline)
{
// the map shows x to the left, z up: a heading from +z towards +x turns up the screen and to the left
var ahead = new Vector(-Math.Sin(heading), -Math.Cos(heading));
var across = new Vector(-ahead.Y, ahead.X);
var tip = plate + ahead * (size * 4.2);
var root = plate + ahead * (size * 1.8);
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(tip, true);
g.LineTo(root + across * (size * 1.3));
g.LineTo(root - across * (size * 1.3));
g.EndFigure(true);
}
context.DrawGeometry(fill, outline, geometry);
}
private void DrawStopPoints(DrawingContext context, Recording recording)
{
if (recording.StopPoints.Count == 0)
return;
var outcomes = new Dictionary<(double, double), StationKind>();
foreach (var station in recording.UserStations())
{
if (station.Time > _time || station.Kind is not (StationKind.Stopped or StationKind.Missed))
continue;
var key = (Math.Round(station.Position.X, 1), Math.Round(station.Position.Z, 1));
if (!outcomes.TryGetValue(key, out var was) || was != StationKind.Missed)
outcomes[key] = station.Kind;
}
// Close up every plate is named; from afar each station once, where the names don't cover each other.
bool near = _scale > 0.35;
double size = _scale > 0.5 ? 4 : 3;
var outline = new Pen(Palette.MapBackground, 1);
var labels = new List<(Point At, string Name, IBrush Fill)>();
foreach (var point in recording.StopPoints)
{
var p = ToScreen(point.Position.X, point.Position.Z);
if (!Visible(p))
continue;
var fill = outcomes.TryGetValue((Math.Round(point.Position.X, 1), Math.Round(point.Position.Z, 1)), out var kind)
? kind == StationKind.Missed ? Palette.Bad : Palette.Station
: Palette.StopPoint;
var plate = new Rect(p.X - size, p.Y - size * 1.3, size * 2, size * 2.6);
// a W32 hollow, in the colour a W4 would be filled with
if (point.ForShortTrains)
context.DrawRectangle(Palette.MapBackground, new Pen(fill, 1.5), plate, 1.5, 1.5);
else
context.DrawRectangle(fill, outline, plate, 1.5, 1.5);
if (point.HasHeading)
DrawStopWay(context, p, point.Heading, size, fill, outline);
labels.Add((p, StationNames.Display(point.Station), fill));
}
var delays = StationProgress.StationDelays(recording, _time);
var named = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var taken = new List<Rect>();
// The label of the user's train stays clear.
if (recording.CabPositionAt(_time) is { } train)
{
var mark = ToScreen(train.X, train.Z);
taken.Add(new Rect(mark.X - 10, mark.Y - 24, 170, 34));
}
var culture = System.Globalization.CultureInfo.CurrentCulture;
foreach (var (p, name, fill) in labels)
{
if (!near && !named.Add(name))
continue;
var text = Text(name, Palette.Text, near ? 11 : 10);
FormattedText? late = delays.TryGetValue(name, out var minutes)
? Text(string.Format(culture, Strings["Minutes"], minutes), Palette.Delay(minutes), near ? 11 : 10)
: null;
double width = text.Width + (late is null ? 0 : late.Width + 6) + 8;
var box = new Rect(p.X + size + 3, p.Y - text.Height / 2 - 1, width, text.Height + 2);
if (taken.Exists(r => r.Inflate(2).Intersects(box)))
continue;
taken.Add(box);
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.8), null, box);
context.DrawText(text, new Point(box.X + 4, box.Y + 1));
if (late is not null)
context.DrawText(late, new Point(box.X + 4 + text.Width + 6, box.Y + 1));
}
}
/// <summary>The horn points (W6): a diamond, green where the user sounded the horn, red where not.</summary>
private void DrawHornPoints(DrawingContext context, Recording recording)
{
if (recording.HornPoints.Count == 0)
return;
var checks = recording.UserHornChecks().Where(c => c.Request.Time <= _time).ToList();
double size = _scale > 0.5 ? 5 : 3.5;
var outline = new Pen(Palette.MapBackground, 1);
foreach (var point in recording.HornPoints)
{
var p = ToScreen(point.Position.X, point.Position.Z);
if (!Visible(p))
continue;
var check = checks.LastOrDefault(c => c.Request.Position.DistanceTo(point.Position) < 1);
var fill = check is null ? Palette.HornPoint : check.Sounded ? Palette.Good : Palette.Bad;
context.DrawGeometry(fill, outline, Diamond(p, size));
}
}
private static StreamGeometry Diamond(Point p, double size)
{
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(p + new Vector(0, -size), true);
g.LineTo(p + new Vector(size, 0));
g.LineTo(p + new Vector(0, size));
g.LineTo(p + new Vector(-size, 0));
g.EndFigure(true);
}
return geometry;
}
/// <summary>Where the user looked from, when not from the cab: a dot with a wedge of where it looked.</summary>
private void DrawCamera(DrawingContext context, Recording recording)
{
if (recording.CameraAt(_time) is not { } camera || camera.Mode == CameraMode.Cab)
return;
var p = ToScreen(camera.Position.X, camera.Position.Z);
if (!Visible(p, 60))
return;
// The heading from +z towards +x: up the screen and to the left.
const double reach = 46, half = 0.45;
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(p, true);
g.LineTo(p + new Vector(-Math.Sin(camera.Heading - half), -Math.Cos(camera.Heading - half)) * reach);
g.ArcTo(p + new Vector(-Math.Sin(camera.Heading + half), -Math.Cos(camera.Heading + half)) * reach, new Size(reach, reach), 0,
false, SweepDirection.CounterClockwise);
g.EndFigure(true);
}
context.DrawGeometry(Palette.Faded(Palette.HornPoint, 0.18), new Pen(Palette.Faded(Palette.HornPoint, 0.5), 1), geometry);
context.DrawEllipse(Palette.HornPoint, new Pen(Palette.MapBackground, 1.5), p, 4.5, 4.5);
}
/// <summary>Where the user's train passed signals; those at danger ringed red.</summary>
private void DrawPassings(DrawingContext context, Recording recording)
{
var passed = new Pen(Palette.Within, 1.5);
var danger = new Pen(Palette.Over, 2.5);
foreach (var passing in recording.UserPassings())
{
var p = ToScreen(passing.Position.X, passing.Position.Z);
if (!Visible(p))
continue;
if (passing.AtDanger)
context.DrawEllipse(null, danger, p, 10, 10);
else if (passing.Time <= _time)
context.DrawEllipse(null, passed, p, 7, 7);
}
}
private void DrawVehicles(DrawingContext context, Recording recording)
{
int user = recording.CabAt(_time) is { } sample && _time - sample.Time < 1.5 ? sample.Vehicle : 0;
foreach (var state in recording.VehiclesAt(_time))
{
var info = recording.Vehicle(state.Id);
var brush = state.Id == user ? Palette.UserTrain : state.Driving == Driver.Ai ? Palette.VehicleAi : Palette.Vehicle;
DrawVehicle(context, state.Position.X, state.Position.Z, state.Heading, info?.Length ?? 15, info?.Width ?? 3, brush);
}
}
private void DrawVehicle(DrawingContext context, double x, double z, double heading, double length, double width,
IBrush brush)
{
var center = ToScreen(x, z);
if (!Visible(center, length * _scale + 10))
return;
// The front from +z towards +x: up the screen and to the left.
double fx = Math.Sin(heading), fz = Math.Cos(heading);
double half = Math.Max(length * _scale, 5) / 2, side = Math.Max(width * _scale, 3) / 2;
var front = new Vector(-fx, -fz);
var left = new Vector(-fz, fx);
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(center + front * half + left * side, true);
g.LineTo(center + front * half - left * side);
g.LineTo(center - front * half - left * side);
g.LineTo(center - front * half + left * side);
g.EndFigure(true);
}
context.DrawGeometry(brush, new Pen(Palette.Background, 0.8), geometry);
}
/// <summary>The head of the user's train between its samples, ten a second, on top of everything.</summary>
private void DrawUserTrain(DrawingContext context, Recording recording)
{
if (recording.CabPositionAt(_time) is not { } position)
return;
var p = ToScreen(position.X, position.Z);
context.DrawEllipse(Palette.MapBackground, new Pen(Palette.UserTrain, 2), p, 7, 7);
context.DrawEllipse(Palette.UserTrain, null, p, 3, 3);
// The train and how late it is, by its mark.
if (StationProgress.DelayAt(recording, _time) is not { } delay)
return;
var culture = System.Globalization.CultureInfo.CurrentCulture;
var train = Text(delay.Train, Palette.UserTrain, 11, FontWeight.SemiBold);
var late = Text(Math.Abs(delay.Minutes) < 0.5 ? Strings["OnTime"] : string.Format(culture, Strings["Minutes"], delay.Minutes),
Palette.Delay(delay.Minutes), 11, FontWeight.SemiBold);
var box = TrainLabel(p, train, late);
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.85), null, box);
context.DrawText(train, new Point(box.X + 4, box.Y + 2));
context.DrawText(late, new Point(box.X + train.Width + 12, box.Y + 2));
}
/// <summary>Where the label of the user's train goes, by its mark.</summary>
private static Rect TrainLabel(Point mark, FormattedText train, FormattedText late) =>
new(mark.X + 12, mark.Y - 22, train.Width + late.Width + 16, train.Height + 4);
private void DrawScale(DrawingContext context, Size size)
{
double metres = Nice(120 / _scale);
double length = metres * _scale;
var y = size.Height - 14;
var pen = new Pen(Palette.DimText, 1.5);
context.DrawLine(pen, new Point(size.Width - 16 - length, y), new Point(size.Width - 16, y));
context.DrawLine(pen, new Point(size.Width - 16 - length, y - 4), new Point(size.Width - 16 - length, y + 1));
context.DrawLine(pen, new Point(size.Width - 16, y - 4), new Point(size.Width - 16, y + 1));
var text = Text(metres >= 1000 ? $"{metres / 1000:0.#} km" : $"{metres:0} m", Palette.DimText, 11);
context.DrawText(text, new Point(size.Width - 16 - length / 2 - text.Width / 2, y - 18));
}
private void DrawLegend(DrawingContext context, Size size)
{
var rows = new (IBrush Brush, string Text)[]
{
(Palette.Within, Strings["LegendWithin"]),
(Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)),
(Palette.Over, Strings["LegendOver"]),
(Palette.Autopilot, Strings["LegendAutopilot"]),
(Palette.NoData, Strings["LegendNoData"])
};
var marks = new List<(IBrush Brush, string Text, int Shape)>();
if (_recording is { } r)
{
if (r.StopPoints.Any(point => !point.ForShortTrains))
marks.Add((Palette.StopPoint, Strings["LegendStopPoint"], 0));
if (r.StopPoints.Any(point => point.ForShortTrains))
marks.Add((Palette.StopPoint, Strings["LegendShortStop"], 3));
if (r.HornPoints.Count > 0)
marks.Add((Palette.HornPoint, Strings["LegendHorn"], 1));
if (r.Cameras.Count > 0)
marks.Add((Palette.HornPoint, Strings["LegendCamera"], 2));
}
var texts = rows.Select(row => Text(row.Text, Palette.DimText, 11)).Concat(marks.Select(m => Text(m.Text, Palette.DimText, 11))).ToList();
const double line = 17;
double width = texts.Max(t => t.Width) + 44, height = texts.Count * line + 14 + (marks.Count > 0 ? 6 : 0);
var box = new Rect(10, size.Height - height - 10, width, height);
context.DrawRectangle(Palette.Faded(Palette.Card, 0.9), new Pen(Palette.CardBorder, 1), box);
double y = box.Top + 7;
for (int i = 0; i < rows.Length; i++, y += line)
{
context.DrawLine(new Pen(rows[i].Brush, 4, lineCap: PenLineCap.Round), new Point(box.Left + 12, y + 8), new Point(box.Left + 26, y + 8));
context.DrawText(texts[i], new Point(box.Left + 34, y));
}
if (marks.Count > 0)
{
context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y + 3), new Point(box.Right - 10, y + 3));
y += 6;
}
for (int i = 0; i < marks.Count; i++, y += line)
{
var c = new Point(box.Left + 19, y + 8);
if (marks[i].Shape == 0)
context.DrawRectangle(marks[i].Brush, null, new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
else if (marks[i].Shape == 3)
context.DrawRectangle(null, new Pen(marks[i].Brush, 1.5), new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
else if (marks[i].Shape == 1)
context.DrawGeometry(marks[i].Brush, null, Diamond(c, 4.5));
else
context.DrawEllipse(marks[i].Brush, null, c, 4, 4);
context.DrawText(texts[rows.Length + i], new Point(box.Left + 34, y));
}
}
/// <summary>The name of the signal or the vehicle under the pointer.</summary>
private void DrawHover(DrawingContext context, Recording recording)
{
if (_pointer is not { } pointer || _dragFrom is not null)
return;
string? label = null;
double best = 10;
foreach (var signal in recording.Signals)
{
double d = Distance(ToScreen(signal.Position.X, signal.Position.Z), pointer);
if (d < best)
{
best = d;
var aspect = recording.AspectAt(signal.Index, _time);
label = aspect is { } a ? $"{signal.Name} · {AspectText(Strings, a.Speed1, a.Speed2)}" : signal.Name;
}
}
var culture = CultureInfo.CurrentCulture;
foreach (var point in recording.StopPoints)
{
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
if (d < best)
{
best = d;
label = string.Format(culture, Strings[point.ForShortTrains ? "ShortStopHover" : "StopPointHover"], StationNames.Display(point.Station));
if (point.Platform > 0)
label += " · " + string.Format(culture, Strings["PlatformHover"], point.PlatformLength);
else if (point.Platform < 0)
label += " · " + string.Format(culture, Strings["ShortTrainsHover"], point.PlatformLength);
// the preferred place to stop, as the driver reads it: the head, or the middle of the train
if (point.StopAt < 0)
label += " · " + string.Format(culture, Strings["StopHeadHover"], -point.StopAt);
else if (point.StopAt > 0)
label += " · " + string.Format(culture, Strings["StopMiddleHover"], point.StopAt);
}
}
foreach (var point in recording.HornPoints)
{
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
if (d < best)
{
best = d;
label = string.Format(culture, Strings["HornHover"], point.Name, point.Duration);
}
}
foreach (var state in recording.VehiclesAt(_time))
{
double d = Distance(ToScreen(state.Position.X, state.Position.Z), pointer);
if (d < best && recording.Vehicle(state.Id) is { } info)
{
best = d;
label = string.IsNullOrEmpty(info.Train)
? $"{info.Name} · {Math.Abs(state.SpeedKmh):0} km/h"
: $"{info.Name} ({info.Train}) · {Math.Abs(state.SpeedKmh):0} km/h";
}
}
if (label is null)
return;
var text = Text(label, Palette.Text, 12);
var box = new Rect(pointer.X + 14, pointer.Y - 12 - text.Height, text.Width + 16, text.Height + 8);
context.DrawRectangle(Palette.Faded(Palette.Card, 0.95), new Pen(Palette.CardBorder, 1), box, 6, 6);
context.DrawText(text, new Point(box.Left + 8, box.Top + 4));
}
/// <summary>An aspect as the speeds it gives: stop, a speed, or go.</summary>
public static string AspectText(IBlackBoxStrings strings, float speed1, float speed2)
{
if (speed1 == 0)
return strings["AspectStop"];
if (speed1 < 0 && speed2 < 0)
return strings["AspectFree"];
string first = speed1 < 0 ? "∞" : speed1.ToString("0", CultureInfo.InvariantCulture);
string second = speed2 < 0 ? "∞" : speed2.ToString("0", CultureInfo.InvariantCulture);
return $"{first}/{second}";
}
private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
private static double Nice(double value)
{
double power = Math.Pow(10, Math.Floor(Math.Log10(value)));
double unit = value / power;
return (unit < 2 ? 1 : unit < 5 ? 2 : 5) * power;
}
internal static FormattedText Text(string text, IBrush brush, double size, FontWeight weight = FontWeight.Normal) =>
new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
weight == FontWeight.Normal ? Typeface.Default : new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, weight),
size, brush);
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var point = e.GetCurrentPoint(this);
if (!point.Properties.IsLeftButtonPressed)
return;
if (e.ClickCount == 2)
{
if (_follow)
StopFollowing();
Fit();
return;
}
_dragFrom = point.Position;
_dragCenter = (_centerX, _centerZ);
e.Pointer.Capture(this);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var position = e.GetPosition(this);
_pointer = position;
if (_dragFrom is { } from)
{
var moved = position - from;
if (_follow && (Math.Abs(moved.X) > 3 || Math.Abs(moved.Y) > 3))
StopFollowing();
if (!_follow)
{
_centerX = _dragCenter.X + moved.X / _scale;
_centerZ = _dragCenter.Z + moved.Y / _scale;
}
}
InvalidateVisual();
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
_dragFrom = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
_pointer = null;
InvalidateVisual();
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
var at = e.GetPosition(this);
var (x, z) = ToWorld(at);
_scale = Math.Clamp(_scale * Math.Pow(1.25, e.Delta.Y), MinScale, MaxScale);
if (!_follow)
{
// The place under the pointer stays under it.
_centerX = x + (at.X - Bounds.Width / 2) / _scale;
_centerZ = z + (at.Y - Bounds.Height / 2) / _scale;
}
InvalidateVisual();
e.Handled = true;
}
private void StopFollowing()
{
_follow = false;
FollowStopped?.Invoke();
}
}

View File

@@ -0,0 +1,113 @@
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The colours the map, the timeline and the panels share: those of the starter's Carbon
/// look for the window, its green for what is picked, and colours of their own for the data.
/// </summary>
public static class Palette
{
// The window, the cards on it and the lines between.
public static readonly IBrush Background = Brush("#15171B");
public static readonly IBrush Panel = Brush("#1E2327");
public static readonly IBrush Card = Brush("#1E2327");
public static readonly IBrush CardHover = Brush("#2A3036");
public static readonly IBrush CardBorder = Brush("#3A424A");
public static readonly IBrush Grid = Brush("#2A3036");
public static readonly IBrush MapBackground = Brush("#121417");
public static readonly IBrush Text = Brush("#E6E8EA");
public static readonly IBrush DimText = Brush("#9098A0");
public static readonly IBrush FaintText = Brush("#646C74");
/// <summary>The headers of the panels, as the group boxes of the starter.</summary>
public static readonly IBrush Header = Brush("#2A3036");
/// <summary>The green of what is picked or on: bright for lines and text, deep for fills.</summary>
public static readonly IBrush Accent = Brush("#41C400");
public static readonly IBrush AccentFill = Brush("#177F00");
public static readonly IBrush AccentFillHover = Brush("#1E9000");
public static readonly IBrush AccentText = Brush("#FFFFFF");
public static readonly IBrush Good = Brush("#4cc46e");
/// <summary>A delay: green on time, yellow a little late, red late, blue early.</summary>
public static IBrush Delay(double? minutes) => minutes switch
{
null => Good,
> 3 => Bad,
> 0.5 => Warn,
< -0.5 => Station,
_ => Good
};
public static readonly IBrush Warn = Brush("#e8c547");
public static readonly IBrush Bad = Brush("#f0584a");
public static readonly IBrush Track = Brush("#6f7b88");
public static readonly IBrush TrackCasing = Brush("#1d232b");
public static readonly IBrush SwitchOff = Brush("#323a44");
public static readonly IBrush Vehicle = Brush("#c9a26b");
public static readonly IBrush VehicleAi = Brush("#9fb0c3");
public static readonly IBrush UserTrain = Brush("#ff9d1c");
public static readonly IBrush Cursor = Brush("#ffb000");
public static readonly IBrush Within = Brush("#4cc46e");
public static readonly IBrush Slightly = Brush("#e8c547");
public static readonly IBrush Over = Brush("#f0584a");
public static readonly IBrush Autopilot = Brush("#4a90e2");
public static readonly IBrush NoData = Brush("#5f6871");
public static readonly IBrush Speed = Brush("#f2f4f6");
public static readonly IBrush SpeedFill = new SolidColorBrush(Color.Parse("#f2f4f6"), 0.06).ToImmutable();
public static readonly IBrush Target = Brush("#4cc46e");
public static readonly IBrush Limit = Brush("#ff8c2a");
public static readonly IBrush SignalStop = Brush("#ff3b30");
public static readonly IBrush SignalLimited = Brush("#ffcc00");
public static readonly IBrush SignalFree = Brush("#34c759");
public static readonly IBrush SignalShunt = Brush("#e8f0ff");
public static readonly IBrush SignalShuntStop = Brush("#4a6cff");
public static readonly IBrush SignalUnknown = Brush("#808890");
public static readonly IBrush StopPoint = Brush("#f4f6f8");
public static readonly IBrush Station = Brush("#5aa9ff");
public static readonly IBrush HornPoint = Brush("#b9a6ff");
public static readonly IBrush Brakes = Brush("#e05a52");
public static readonly IBrush Controller = Brush("#2bb3a3");
public static readonly IBrush Vigilance = Brush("#e8c547");
public static readonly IBrush CabSignal = Brush("#f08a3a");
public static readonly IBrush LampOff = Brush("#252c35");
public static readonly IBrush LampOn = Brush("#4cc46e");
public static readonly IBrush LampWarning = Brush("#e8c547");
public static readonly IBrush LampAlarm = Brush("#f0584a");
public static readonly IBrush LightWhite = Brush("#fff6d8");
public static readonly IBrush LightRed = Brush("#ff3b30");
public static readonly IBrush Command = Brush("#8b96a3");
public static IBrush Of(SpeedJudgement judgement) => judgement switch
{
SpeedJudgement.Within => Within,
SpeedJudgement.Slightly => Slightly,
SpeedJudgement.Over => Over,
SpeedJudgement.Autopilot => Autopilot,
_ => NoData
};
/// <summary>What a signal shows: the colour of its main light, as the in-game map draws it.</summary>
public static IBrush Of(AspectChange? aspect)
{
if (aspect is not { } a)
return SignalUnknown;
bool shunt = a.Command.StartsWith("Shunt", System.StringComparison.OrdinalIgnoreCase);
if (a.Speed1 == 0)
return shunt ? SignalShuntStop : SignalStop;
if (shunt)
return SignalShunt;
return a.Speed1 < 0 && a.Speed2 != 0 ? SignalFree : SignalLimited;
}
/// <summary>A colour seen through: for fills under lines and glows.</summary>
public static IBrush Faded(IBrush brush, double opacity) =>
new SolidColorBrush(((ISolidColorBrush)brush).Color, opacity).ToImmutable();
private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)).ToImmutable();
}

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>A moment was clicked.</summary>
public event Action<double>? Seek;
/// <summary>The moment under the pointer, null once it leaves.</summary>
public event Action<double?>? 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();
}
}
/// <summary>All of the recording in view.</summary>
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);
}
/// <summary>The top of the speed scale and the highest step of the controller, once a reading.</summary>
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<double, double> 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<double, double> 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));
}
}
/// <summary>
/// Each curve by columns of pixels: in a column the lowest and the highest speed,
/// so an hour draws as fast as a minute.
/// </summary>
private void DrawCurves(DrawingContext context, Recording recording, Func<double, double> 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<Point>();
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;
}
/// <summary>The harder the brakes hold, the deeper the red.</summary>
private static IBrush BrakeShade(float cylinderBar) =>
BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>A line through columns of pixels: in each the first, the lowest, the highest and the last value.</summary>
private sealed class Curve
{
private readonly List<List<Point>> _lines = new();
private List<Point>? _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<Point>());
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));
}
/// <summary>The area under the line down to the bottom given, for a soft fill.</summary>
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;
}
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// 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.
/// </summary>
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<StationProgress> _rows = Array.Empty<StationProgress>();
private Timetable? _timetable;
private int _hover = -1;
public event Action<double>? 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<StationProgress>();
_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);
/// <summary>The row of the station the train was at, or made for, at the moment played.</summary>
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;
}
}
}
/// <summary>What happened at a station, each part with its colour.</summary>
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));
}
}

View File

@@ -0,0 +1,283 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
namespace Eu07.BlackBox.Views;
/// <summary>
/// The pieces the window is built of, in one plain look: flat panels side by side, flat
/// buttons, a table of figures and tabs underlined.
/// </summary>
internal static class UiKit
{
/// <summary>The gutter between the panels.</summary>
public const double Gap = 6;
public static readonly CornerRadius Small = new(2);
/// <summary>
/// The theme's accent (the slider, the picked lines, the focus) in the orange of the viewer
/// instead of the system colour, for the window given and everything in it.
/// </summary>
public static void Accent(Control control)
{
// As the starter's App.axaml sets them, for the viewer on its own.
(string Key, string Colour)[] accents =
{
("SystemAccentColor", "#177f00"), ("SystemAccentColorDark1", "#136B00"), ("SystemAccentColorDark2", "#0F5600"),
("SystemAccentColorDark3", "#0B4200"), ("SystemAccentColorLight1", "#2A9A0F"), ("SystemAccentColorLight2", "#41C400"),
("SystemAccentColorLight3", "#5FD42A")
};
foreach (var (key, colour) in accents)
control.Resources[key] = Color.Parse(colour);
control.Resources["AccentFillColorDefaultBrush"] = Palette.AccentFill;
control.Resources["AccentFillColorSecondaryBrush"] = Palette.AccentFillHover;
control.Resources["SystemControlHighlightListAccentLowBrush"] = Palette.AccentFill;
control.Resources["SystemControlHighlightListAccentMediumBrush"] = Palette.AccentFillHover;
control.Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
control.Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
control.Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
control.Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
}
/// <summary>A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.</summary>
public static Border Group(string title, Control content, Control? tag = null)
{
var bar = new DockPanel();
if (tag is not null)
{
DockPanel.SetDock(tag, Dock.Right);
tag.VerticalAlignment = VerticalAlignment.Center;
bar.Children.Add(tag);
}
bar.Children.Add(new TextBlock
{
Text = title,
FontSize = 12,
FontWeight = FontWeight.SemiBold,
Foreground = Palette.Text,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center
});
var header = new Border
{
Background = Palette.Header,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(8, 3),
MinHeight = 24,
Child = bar
};
var panel = new DockPanel();
DockPanel.SetDock(header, Dock.Top);
panel.Children.Add(header);
panel.Children.Add(content);
return Frame(panel);
}
/// <summary>A panel in the frame of the starter's group boxes, without a title.</summary>
public static Border Frame(Control content) => new()
{
Background = Palette.Panel,
BorderBrush = Palette.CardBorder,
BorderThickness = new Thickness(1),
CornerRadius = Small,
ClipToBounds = true,
Child = content
};
/// <summary>A flat panel the views sit on.</summary>
public static Border Card(Control content, Thickness? padding = null) => new()
{
Background = Palette.Card,
Padding = padding ?? new Thickness(0),
ClipToBounds = true,
Child = content
};
/// <summary>A button of the toolbar: text on the bar, a lighter ground under the pointer.</summary>
public static Button Button(string text, string? tip = null)
{
var button = new Button
{
Content = text,
Padding = new Thickness(10, 2),
MinHeight = 25,
CornerRadius = Small,
FontSize = 12,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
if (tip is not null)
ToolTip.SetTip(button, tip);
return button;
}
/// <summary>The button of playing.</summary>
public static Button PlayButton()
{
var button = new Button
{
Width = 32,
Height = 25,
CornerRadius = Small,
Background = Palette.AccentFill,
Padding = new Thickness(0),
BorderThickness = new Thickness(0),
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
// As the starter's start button: its green, brighter under the pointer.
Hover(button, Palette.Accent, Brushes.White);
return button;
}
/// <summary>The glyph of play or pause.</summary>
public static Control PlayGlyph(bool playing)
{
var geometry = playing
? Geometry.Parse("M0,0 H4 V12 H0 Z M7,0 H11 V12 H7 Z")
: Geometry.Parse("M1,0 L11,6 L1,12 Z");
return new Avalonia.Controls.Shapes.Path
{
Data = geometry,
Fill = Brushes.White,
Width = 12,
Height = 12,
Stretch = Stretch.None,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
}
/// <summary>A toggle of the toolbar: on, its text takes the accent.</summary>
public static ToggleButton Toggle(string text, string? tip = null)
{
var toggle = new ToggleButton
{
Content = text,
Padding = new Thickness(10, 2),
MinHeight = 25,
CornerRadius = Small,
FontSize = 12,
VerticalContentAlignment = VerticalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
foreach (var state in new[] { "", "PointerOver", "Pressed" })
{
toggle.Resources["ToggleButtonBackgroundChecked" + state] = state == "PointerOver" ? Palette.AccentFillHover : Palette.AccentFill;
toggle.Resources["ToggleButtonForegroundChecked" + state] = Brushes.White;
}
if (tip is not null)
ToolTip.SetTip(toggle, tip);
return toggle;
}
// The theme takes its own colours under the pointer and when pressed, unless the control gives them.
private static void Hover(Control control, IBrush background, IBrush foreground)
{
foreach (var kind in new[] { "Button", "ToggleButton" })
{
control.Resources[kind + "BackgroundPointerOver"] = background;
control.Resources[kind + "BackgroundPressed"] = background;
control.Resources[kind + "ForegroundPointerOver"] = foreground;
control.Resources[kind + "ForegroundPressed"] = foreground;
control.Resources[kind + "BorderBrushPointerOver"] = Brushes.Transparent;
control.Resources[kind + "BorderBrushPressed"] = Brushes.Transparent;
}
}
/// <summary>A line of the table of figures: what it is on the left, its value on the right, a note under it.</summary>
public sealed class Tile : Border
{
private readonly TextBlock _value = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, HorizontalAlignment = HorizontalAlignment.Right };
private readonly TextBlock _sub = new() { FontSize = 11, Foreground = Palette.FaintText, TextTrimming = TextTrimming.CharacterEllipsis };
public Tile(string caption)
{
BorderBrush = Palette.Grid;
BorderThickness = new Thickness(0, 0, 0, 1);
Padding = new Thickness(8, 4);
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), RowDefinitions = new RowDefinitions("Auto,Auto") };
var name = new TextBlock { Text = caption, FontSize = 12, Foreground = Palette.DimText, VerticalAlignment = VerticalAlignment.Center };
Grid.SetColumn(_value, 1);
Grid.SetRow(_sub, 1);
Grid.SetColumnSpan(_sub, 2);
grid.Children.Add(name);
grid.Children.Add(_value);
grid.Children.Add(_sub);
Child = grid;
}
public void Show(string value, string? sub = null, IBrush? brush = null)
{
_value.Text = value;
_value.Foreground = brush ?? Palette.Text;
_sub.Text = sub ?? "";
_sub.IsVisible = !string.IsNullOrEmpty(sub);
}
}
/// <summary>Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.</summary>
public sealed class Segmented : Border
{
private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
private int _selected = -1;
public event Action<int>? SelectedChanged;
public Segmented(params string[] choices)
{
Background = Palette.Background;
BorderBrush = Palette.CardBorder;
BorderThickness = new Thickness(0, 0, 0, 1);
Child = _row;
for (int i = 0; i < choices.Length; i++)
{
int index = i;
var button = new Button
{
Content = choices[i],
Padding = new Thickness(14, 3),
MinHeight = 24,
CornerRadius = new CornerRadius(0),
FontSize = 12,
BorderThickness = new Thickness(0, 2, 0, 0)
};
button.Resources["ButtonBackgroundPointerOver"] = Palette.CardHover;
button.Resources["ButtonBackgroundPressed"] = Palette.CardHover;
button.Resources["ButtonForegroundPointerOver"] = Palette.Text;
button.Resources["ButtonForegroundPressed"] = Palette.Text;
button.Click += (_, _) => Selected = index;
_row.Children.Add(button);
}
Selected = 0;
}
public int Selected
{
get => _selected;
set
{
if (value == _selected)
return;
_selected = value;
for (int i = 0; i < _row.Children.Count; i++)
{
var button = (Button)_row.Children[i];
bool on = i == value;
var line = on ? Palette.Accent : Brushes.Transparent;
button.Background = on ? Palette.Panel : Brushes.Transparent;
button.BorderBrush = line;
button.Resources["ButtonBorderBrushPointerOver"] = line;
button.Resources["ButtonBorderBrushPressed"] = line;
button.Foreground = on ? Palette.Text : Palette.DimText;
}
SelectedChanged?.Invoke(value);
}
}
}
}