diff --git a/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj b/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
new file mode 100644
index 0000000..925103a
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
@@ -0,0 +1,30 @@
+
+
+
+ WinExe
+ net9.0
+ enable
+ true
+ true
+ true
+ true
+ app.manifest
+ ..\StarterNG\Assets\starter.ico
+ BlackBoxViewer
+ Eu07.BlackBox.Viewer
+ 1.0
+ eu07.pl
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Viewer/Program.cs b/Eu07.BlackBox.Viewer/Program.cs
new file mode 100644
index 0000000..c99429e
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Program.cs
@@ -0,0 +1,109 @@
+using System;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Styling;
+using Avalonia.Themes.Fluent;
+using Eu07.BlackBox.Views;
+
+namespace Eu07.BlackBox.Viewer;
+
+///
+/// BlackBoxViewer [file.blackbox] [--live] [--time seconds] [--follow] [--timetable | --summary] [--screenshot out.png]
+/// --live follows a file the simulator still writes; --screenshot saves the window
+/// at the moment given and quits, for documentation and checks.
+///
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont();
+}
+
+internal sealed class ViewerApp : Application
+{
+ public override void Initialize()
+ {
+ Styles.Add(new FluentTheme());
+ RequestedThemeVariant = ThemeVariant.Dark;
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var options = ViewerOptions.Parse(desktop.Args ?? Array.Empty());
+ var window = new BlackBoxWindow { WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterScreen };
+ window.Live = options.Live;
+ desktop.MainWindow = window;
+ window.Opened += async (_, _) =>
+ {
+ if (options.File is { } file)
+ await window.OpenAsync(Path.GetFullPath(file));
+ window.ShowTimetable = options.Timetable;
+ if (options.Summary)
+ window.SummaryShown = true;
+ if (options.Screenshot is { } shot)
+ {
+ await Screenshot.SaveAsync(window, shot, options.Time, options.Follow);
+ return;
+ }
+ if (options.Time is { } time)
+ window.SeekTo(time);
+ window.FollowTrain = options.Follow;
+ };
+ }
+ base.OnFrameworkInitializationCompleted();
+ }
+}
+
+internal sealed record ViewerOptions(string? File, bool Live, double? Time, string? Screenshot, bool Follow, bool Timetable,
+ bool Summary = false)
+{
+ public static ViewerOptions Parse(string[] args)
+ {
+ string? file = null, screenshot = null;
+ double? time = null;
+ bool live = false, follow = false, timetable = false, summary = false;
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--live":
+ live = true;
+ break;
+ case "--follow":
+ follow = true;
+ break;
+ case "--timetable":
+ timetable = true;
+ break;
+ case "--summary":
+ summary = true;
+ break;
+ case "--time" when i + 1 < args.Length &&
+ double.TryParse(args[i + 1], System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var t):
+ time = t;
+ ++i;
+ break;
+ case "--screenshot" when i + 1 < args.Length:
+ screenshot = args[++i];
+ break;
+ default:
+ if (!args[i].StartsWith("--", StringComparison.Ordinal))
+ file ??= args[i];
+ break;
+ }
+ }
+ return new ViewerOptions(file, live, time, screenshot, follow, timetable, summary);
+ }
+}
diff --git a/Eu07.BlackBox.Viewer/Screenshot.cs b/Eu07.BlackBox.Viewer/Screenshot.cs
new file mode 100644
index 0000000..0b8e146
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/Screenshot.cs
@@ -0,0 +1,29 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Eu07.BlackBox.Views;
+
+namespace Eu07.BlackBox.Viewer;
+
+/// The window as it shows a moment, saved to a PNG; then the viewer closes.
+internal static class Screenshot
+{
+ public static async Task SaveAsync(BlackBoxWindow window, string path, double? time, bool follow)
+ {
+ if (time is { } t)
+ window.SeekTo(t);
+ window.FollowTrain = follow;
+ // A few frames for the layout and the first render.
+ await Task.Delay(1000);
+ // Not laid out (a minimised window): nothing to save, and the viewer still closes.
+ if (window.Content is Control content && content.Bounds.Width >= 1 && content.Bounds.Height >= 1)
+ {
+ var size = new PixelSize((int)content.Bounds.Width, (int)content.Bounds.Height);
+ using var bitmap = new RenderTargetBitmap(size);
+ bitmap.Render(content);
+ bitmap.Save(path, new PngBitmapEncoderOptions());
+ }
+ window.Close();
+ }
+}
diff --git a/Eu07.BlackBox.Viewer/app.manifest b/Eu07.BlackBox.Viewer/app.manifest
new file mode 100644
index 0000000..6a5568d
--- /dev/null
+++ b/Eu07.BlackBox.Viewer/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Views/BlackBoxStrings.cs b/Eu07.BlackBox.Views/BlackBoxStrings.cs
new file mode 100644
index 0000000..645599d
--- /dev/null
+++ b/Eu07.BlackBox.Views/BlackBoxStrings.cs
@@ -0,0 +1,579 @@
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+public interface IBlackBoxStrings
+{
+ string this[string key] { get; }
+}
+
+public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
+{
+ private static readonly Dictionary 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}",
+ ["LegendTitle"] = "The train's way: its speed against the AI's",
+ ["LegendWithin"] = "no faster than the AI",
+ ["LegendSlightly"] = "up to {0:0} km/h faster than the AI",
+ ["LegendOver"] = "over {0:0} km/h faster than the AI",
+ ["LegendAutopilot"] = "the autopilot drove",
+ ["LegendNoData"] = "no AI to compare with",
+ ["LegendMarks"] = "Signs on the map",
+ ["TimelineSpeed"] = "speed",
+ ["TimelineTarget"] = "AI",
+ ["TimelineLimit"] = "next limit",
+ ["BandEvents"] = "events",
+ ["KeyStation"] = "station",
+ ["KeyHorn"] = "W6 horn",
+ ["KeySignal"] = "signal passed",
+ ["KeyCoupling"] = "coupling",
+ ["KeyDriver"] = "autopilot on/off",
+ ["KeyCommand"] = "command",
+ ["TlSignal"] = "signal {0} passed: {1}",
+ ["TlSignalDanger"] = "signal {0} passed at stop",
+ ["TlCoupled"] = "coupled",
+ ["TlUncoupled"] = "uncoupled",
+ ["TlCouplingChanged"] = "a coupling changed",
+ ["TlAutopilotOn"] = "the autopilot switched on",
+ ["TlAutopilotOff"] = "the user takes over",
+ ["TlStationStopped"] = "{0}: stopped",
+ ["TlStationPassed"] = "{0}: passed",
+ ["TlStationMissed"] = "{0}: missed",
+ ["TlHornSounded"] = "W6: the horn sounded",
+ ["TlHornSilent"] = "W6: no horn",
+ ["TlCommand"] = "command: {0}",
+ ["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 open",
+ ["DevDoorsRight"] = "Right doors open",
+ ["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"] = "{0:0.0} m of the train 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",
+ ["CabSectionControls"] = "Controls",
+ ["CabSectionDevices"] = "Devices",
+ ["CabGroupPower"] = "Power",
+ ["CabGroupDriving"] = "Driving",
+ ["CabGroupSafety"] = "Safety",
+ ["CabLightsWant"] = "{0}, should be {1}",
+ ["CabLightsNone"] = "no lamps",
+ ["CabBrakeSetting"] = "setting {0}",
+ ["CabDriverValve"] = "Driver's valve",
+ ["CabFeedPipe"] = "Main reservoir pipe",
+ ["CabConsistCylinder"] = "Cylinders, train max",
+ ["CabPower"] = "Power",
+ ["CabSpeedControl"] = "Cruise control",
+ ["CabOilPressure"] = "Oil pressure",
+ ["CabWater"] = "Water",
+ ["CabOil"] = "Oil",
+ ["CabEngine"] = "Engine",
+ ["CabNoDrive"] = "no drive of its own",
+ ["CabClutch"] = "Clutch",
+ ["CabConverterFill"] = "Converter",
+ ["CabIntegrated"] = "Driving handle",
+ ["CabGroupDoors"] = "Doors",
+ ["DevDoorPermitLeft"] = "Left doors permitted",
+ ["DevDoorPermitRight"] = "Right doors permitted",
+ ["DevDepartureSignal"] = "Departure signal",
+ ["DevDoorStep"] = "Steps",
+ ["DevDoorLock"] = "Door lock",
+ ["DevDoorsLocked"] = "Doors locked",
+ ["DevBrakeReleaser"] = "Brake releaser",
+ ["DevFuelPump"] = "Fuel pump",
+ ["DevOilPump"] = "Oil pump",
+ ["DevMotorBlowers"] = "Motor fans",
+ ["DevEngineHot"] = "Engine overheating",
+ ["DevSpeedControl"] = "Cruise control",
+ ["DevRadioChannel"] = "Radio, channel {0}",
+ ["DevHandBrake"] = "Hand brake",
+ ["DevHornTone"] = "Horn ({0})",
+ ["HornLow"] = "low",
+ ["HornHigh"] = "high",
+ ["HornBoth"] = "both",
+ ["EventDeviceOn"] = "{0}: on",
+ ["EventDeviceOff"] = "{0}: off",
+ ["EventDoorsLeftClosed"] = "Left doors closed",
+ ["EventDoorsRightClosed"] = "Right doors closed",
+ ["EventMovingDoorsOpen"] = "Moving with the doors open ({0:0} km/h)",
+ ["EventHandBrakeOn"] = "Hand brake applied",
+ ["EventHandBrakeOff"] = "Hand brake released",
+ ["EventBrakeSetting"] = "Brake setting: {0}",
+ ["EventRadioChannel"] = "Radio: channel {0}",
+ ["EventSpeedControlSet"] = "Cruise control: {0:0} km/h",
+ ["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 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}",
+ ["LegendTitle"] = "Ślad pociągu: prędkość wobec AI",
+ ["LegendWithin"] = "nie szybciej niż AI",
+ ["LegendSlightly"] = "do {0:0} km/h szybciej niż AI",
+ ["LegendOver"] = "ponad {0:0} km/h szybciej niż AI",
+ ["LegendAutopilot"] = "prowadził autopilot",
+ ["LegendNoData"] = "brak AI do porównania",
+ ["LegendMarks"] = "Znaki na mapie",
+ ["TimelineSpeed"] = "prędkość",
+ ["TimelineTarget"] = "AI",
+ ["TimelineLimit"] = "następne ograniczenie",
+ ["BandEvents"] = "zdarzenia",
+ ["KeyStation"] = "stacja",
+ ["KeyHorn"] = "W6 trąbienie",
+ ["KeySignal"] = "minięty semafor",
+ ["KeyCoupling"] = "sprzęg",
+ ["KeyDriver"] = "autopilot wł./wył.",
+ ["KeyCommand"] = "polecenie",
+ ["TlSignal"] = "semafor {0} minięty: {1}",
+ ["TlSignalDanger"] = "semafor {0} minięty na „Stój”",
+ ["TlCoupled"] = "sprzęg połączony",
+ ["TlUncoupled"] = "sprzęg rozłączony",
+ ["TlCouplingChanged"] = "zmiana na sprzęgu",
+ ["TlAutopilotOn"] = "autopilot włączony",
+ ["TlAutopilotOff"] = "prowadzenie przejmuje użytkownik",
+ ["TlStationStopped"] = "{0}: postój",
+ ["TlStationPassed"] = "{0}: przejazd",
+ ["TlStationMissed"] = "{0}: pominięta",
+ ["TlHornSounded"] = "W6: zatrąbiono",
+ ["TlHornSilent"] = "W6: bez trąbienia",
+ ["TlCommand"] = "polecenie: {0}",
+ ["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 otwarte",
+ ["DevDoorsRight"] = "Drzwi prawe otwarte",
+ ["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"] = "{0:0.0} m składu 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",
+ ["CabSectionControls"] = "Nastawniki",
+ ["CabSectionDevices"] = "Urządzenia",
+ ["CabGroupPower"] = "Zasilanie",
+ ["CabGroupDriving"] = "Jazda",
+ ["CabGroupSafety"] = "Bezpieczeństwo",
+ ["CabLightsWant"] = "{0}, a ma być {1}",
+ ["CabLightsNone"] = "brak lamp",
+ ["CabBrakeSetting"] = "nastawa {0}",
+ ["CabDriverValve"] = "Zawór maszynisty",
+ ["CabFeedPipe"] = "Przewód zasilający",
+ ["CabConsistCylinder"] = "Cylindry, skład max",
+ ["CabPower"] = "Moc",
+ ["CabSpeedControl"] = "Tempomat",
+ ["CabOilPressure"] = "Ciśn. oleju",
+ ["CabWater"] = "Woda",
+ ["CabOil"] = "Olej",
+ ["CabEngine"] = "Silnik",
+ ["CabNoDrive"] = "brak własnego napędu",
+ ["CabClutch"] = "Sprzęgło",
+ ["CabConverterFill"] = "Przekładnia",
+ ["CabIntegrated"] = "Zadajnik",
+ ["CabGroupDoors"] = "Drzwi",
+ ["DevDoorPermitLeft"] = "Zezwolenie lewe",
+ ["DevDoorPermitRight"] = "Zezwolenie prawe",
+ ["DevDepartureSignal"] = "Sygnał odjazdu",
+ ["DevDoorStep"] = "Stopień",
+ ["DevDoorLock"] = "Blokada drzwi",
+ ["DevDoorsLocked"] = "Drzwi zablokowane",
+ ["DevBrakeReleaser"] = "Odluźniacz",
+ ["DevFuelPump"] = "Pompa paliwa",
+ ["DevOilPump"] = "Pompa oleju",
+ ["DevMotorBlowers"] = "Wentylatory silników",
+ ["DevEngineHot"] = "Przegrzanie silnika",
+ ["DevSpeedControl"] = "Tempomat",
+ ["DevRadioChannel"] = "Radio, kanał {0}",
+ ["DevHandBrake"] = "Hamulec ręczny",
+ ["DevHornTone"] = "Syrena ({0})",
+ ["HornLow"] = "niski",
+ ["HornHigh"] = "wysoki",
+ ["HornBoth"] = "oba",
+ ["EventDeviceOn"] = "{0}: wł.",
+ ["EventDeviceOff"] = "{0}: wył.",
+ ["EventDoorsLeftClosed"] = "Drzwi lewe zamknięte",
+ ["EventDoorsRightClosed"] = "Drzwi prawe zamknięte",
+ ["EventMovingDoorsOpen"] = "Jazda z otwartymi drzwiami ({0:0} km/h)",
+ ["EventHandBrakeOn"] = "Hamulec ręczny zaciągnięty",
+ ["EventHandBrakeOff"] = "Hamulec ręczny zwolniony",
+ ["EventBrakeSetting"] = "Nastawa hamulca: {0}",
+ ["EventRadioChannel"] = "Radiotelefon: kanał {0}",
+ ["EventSpeedControlSet"] = "Tempomat: {0:0} km/h",
+ ["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 _texts;
+
+ public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
+ {
+ }
+
+ public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
+ {
+ }
+
+ /// Polish for "pl", English for any other language.
+ public DefaultBlackBoxStrings(string languageCode)
+ {
+ _texts = languageCode == "pl" ? Polish : English;
+ }
+
+ /// The keys there are texts for, for a translation to give them all.
+ public static IEnumerable Keys => English.Keys;
+
+ public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
+}
diff --git a/Eu07.BlackBox.Views/BlackBoxWindow.cs b/Eu07.BlackBox.Views/BlackBoxWindow.cs
new file mode 100644
index 0000000..987a2aa
--- /dev/null
+++ b/Eu07.BlackBox.Views/BlackBoxWindow.cs
@@ -0,0 +1,575 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// Replays a black box. Above: the train and the scenery, playing and how late the train
+/// was. In the middle the map at the moment played, the timeline of the user's train under
+/// it; beside them the events, the timetable or the figures of the driving, and the cab. A file
+/// the simulator still writes can be followed: what it adds is read every two seconds, and
+/// playing at its end keeps up with it.
+///
+public sealed class BlackBoxWindow : Window
+{
+ private static readonly double[] Speeds = { 1, 2, 5, 10, 30, 60 };
+
+ private readonly IBlackBoxStrings _strings;
+ private readonly MapView _map;
+ private readonly TimelineView _timeline;
+ private readonly EventList _events;
+ private readonly TimetablePanel _timetable;
+ private readonly ScrollViewer _timetableScroll;
+ private readonly ScrollViewer _summaryScroll;
+ private readonly CabPanel _cab;
+ private readonly UiKit.Segmented _tabs;
+ private readonly TextBlock _title = new() { FontSize = 14, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, TextTrimming = TextTrimming.CharacterEllipsis };
+ private readonly TextBlock _subtitle = new() { FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
+ private readonly TextBlock _clock = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, MinWidth = 86, TextAlignment = TextAlignment.Center };
+ private readonly TextBlock _delayText = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 };
+ private readonly TextBlock _readout = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
+ private readonly TextBlock _fileState = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.FaintText };
+ private readonly TextBlock _message = new() { FontSize = 14, Foreground = Palette.DimText, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Margin = new Thickness(40) };
+ private readonly StackPanel _tiles = new();
+ private readonly UiKit.Tile _distance, _maxSpeed, _driven, _overAi, _signals, _stops, _delay, _platform, _lights, _horn, _acceleration, _security, _state;
+ private readonly Button _open;
+ private readonly Button _play;
+ private readonly ComboBox _speed = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 64, FontSize = 12, Padding = new Thickness(8, 3) };
+ private readonly Avalonia.Controls.Primitives.ToggleButton _follow;
+ private readonly Avalonia.Controls.Primitives.ToggleButton _live;
+ private readonly DispatcherTimer _tick = new() { Interval = TimeSpan.FromMilliseconds(40) };
+ private readonly DispatcherTimer _reload = new() { Interval = TimeSpan.FromSeconds(2) };
+ private readonly Stopwatch _played = new();
+
+ private BlackBoxReader? _reader;
+ private string? _path;
+ private double _time;
+ private bool _reading;
+
+ public BlackBoxWindow() : this(null)
+ {
+ }
+
+ public BlackBoxWindow(IBlackBoxStrings? strings)
+ {
+ _strings = strings ?? new DefaultBlackBoxStrings();
+ Title = _strings["Title"];
+ Width = 1440;
+ Height = 900;
+ MinWidth = 960;
+ MinHeight = 600;
+ Background = Palette.Background;
+ UiKit.Accent(this);
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+ _map = new MapView { Strings = _strings };
+ _timeline = new TimelineView { Strings = _strings };
+ _events = new EventList { Strings = _strings, Background = Brushes.Transparent };
+ _timetable = new TimetablePanel { Strings = _strings };
+ _timetableScroll = new ScrollViewer { Content = _timetable, IsVisible = false };
+ _summaryScroll = new ScrollViewer { Content = _tiles, IsVisible = false };
+ _cab = new CabPanel { Strings = _strings };
+
+ // ----- the bar on top: the train, playing, the switches -----
+ _open = UiKit.Button(_strings["Open"]);
+ _open.Click += async (_, _) => await PickAsync();
+ var reload = UiKit.Button(_strings["Reload"]);
+ reload.Click += (_, _) => ReadMore();
+ var fit = UiKit.Button(_strings["Fit"]);
+ fit.Click += (_, _) => ShowWhole();
+ _follow = UiKit.Toggle(_strings["Follow"]);
+ _follow.IsCheckedChanged += (_, _) => _map.Follow = _follow.IsChecked == true;
+ _map.FollowStopped += () => _follow.IsChecked = false;
+ _live = UiKit.Toggle(_strings["Live"], _strings["LiveTip"]);
+ _live.IsCheckedChanged += (_, _) =>
+ {
+ if (_live.IsChecked == true)
+ _reload.Start();
+ else
+ _reload.Stop();
+ };
+
+ _play = UiKit.PlayButton();
+ _play.Content = UiKit.PlayGlyph(false);
+ ToolTip.SetTip(_play, _strings["Play"]);
+ _play.Click += (_, _) => Playing = !Playing;
+ _speed.ItemsSource = Speeds.Select(s => $"×{s.ToString(CultureInfo.InvariantCulture)}").ToArray();
+ _speed.SelectedIndex = 3;
+
+ var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Children = { _title, _subtitle } };
+ var transport = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, VerticalAlignment = VerticalAlignment.Center, Children = { _play, _clock, _speed, _delayText } };
+ var switches = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { _follow, _live, fit, reload, _open } };
+ var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") };
+ Place(header, titles, 0, 0);
+ Place(header, transport, 0, 1);
+ switches.HorizontalAlignment = HorizontalAlignment.Right;
+ Place(header, switches, 0, 2);
+ titles.Margin = new Thickness(0, 0, 16, 0);
+ var headerBar = new Border
+ {
+ Background = Palette.Panel,
+ BorderBrush = Palette.CardBorder,
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ Padding = new Thickness(8, 0),
+ Height = 44,
+ Child = header
+ };
+
+ // ----- the figures, a table in a tab of their own -----
+ _distance = Tile("StatDistance");
+ _maxSpeed = Tile("StatMaxSpeed");
+ _driven = Tile("StatDriven");
+ _overAi = Tile("StatOverAi");
+ _signals = Tile("StatSignals");
+ _stops = Tile("StatStops");
+ _delay = Tile("StatDelay");
+ _platform = Tile("StatPlatform");
+ _lights = Tile("StatLights");
+ _horn = Tile("StatHorn");
+ _acceleration = Tile("StatAcceleration");
+ _security = Tile("StatSecurity");
+ _state = Tile("StatRecording");
+
+ _timeline.Seek += t => SetTime(t);
+ _timeline.Hovered += ShowReadout;
+ _events.Seek += t => SetTime(t);
+ _timetable.Seek += t => SetTime(t);
+ _tick.Tick += (_, _) => Advance();
+ _reload.Tick += (_, _) => ReadMore();
+
+ // ----- the map with its zoom, the timeline, the side -----
+ var zoomIn = MapButton("+", _strings["ZoomIn"], () => _map.ZoomBy(1.6));
+ var zoomOut = MapButton("−", _strings["ZoomOut"], () => _map.ZoomBy(1 / 1.6));
+ var zoomFit = MapButton("⤢", _strings["Fit"], ShowWhole);
+ var zoom = new StackPanel
+ {
+ Spacing = 1,
+ Margin = new Thickness(8),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Children = { zoomIn, zoomOut, zoomFit }
+ };
+ var mapArea = new Panel { Children = { _map, zoom, _message } };
+ _map.ClipToBounds = true;
+
+ _tabs = new UiKit.Segmented(_strings["TabEvents"], _strings["TabTimetable"], _strings["TabSummary"]);
+ _tabs.SelectedChanged += index =>
+ {
+ _events.IsVisible = index == 0;
+ _timetableScroll.IsVisible = index == 1;
+ _summaryScroll.IsVisible = index == 2;
+ };
+ var side = new DockPanel();
+ DockPanel.SetDock(_tabs, Dock.Top);
+ side.Children.Add(_tabs);
+ side.Children.Add(new Panel { Children = { _events, _timetableScroll, _summaryScroll } });
+ var cabCard = UiKit.Group(_strings["Cab"], new ScrollViewer { Content = _cab, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled });
+ cabCard.Margin = new Thickness(0, UiKit.Gap, 0, 0);
+ var sideColumn = new DockPanel();
+ // On a short window the cab scrolls, so the list above keeps room for a few rows.
+ sideColumn.SizeChanged += (_, e) => cabCard.MaxHeight = Math.Max(160, e.NewSize.Height - 160);
+ DockPanel.SetDock(cabCard, Dock.Bottom);
+ sideColumn.Children.Add(cabCard);
+ sideColumn.Children.Add(UiKit.Frame(side));
+
+ var body = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},400"),
+ RowDefinitions = new RowDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},230")
+ };
+ Place(body, UiKit.Group(_strings["PanelMap"], mapArea), 0, 0);
+ Place(body, new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Rows, MinHeight = UiKit.Gap }, 1, 0);
+ Place(body, UiKit.Group(_strings["PanelTimeline"], _timeline), 2, 0);
+ var columns = new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Columns, MinWidth = UiKit.Gap };
+ Place(body, columns, 0, 1);
+ Grid.SetRowSpan(columns, 3);
+ Place(body, sideColumn, 0, 2);
+ Grid.SetRowSpan(sideColumn, 3);
+
+ // ----- the line at the bottom: the moment under the pointer, the state of the file -----
+ var status = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
+ Place(status, _readout, 0, 0);
+ Place(status, _fileState, 0, 1);
+ var statusBar = new Border
+ {
+ Background = Palette.Panel,
+ BorderBrush = Palette.CardBorder,
+ BorderThickness = new Thickness(0, 1, 0, 0),
+ Padding = new Thickness(12, 3),
+ Child = status
+ };
+
+ body.Margin = new Thickness(UiKit.Gap);
+ var root = new DockPanel { Background = Palette.Background };
+ DockPanel.SetDock(headerBar, Dock.Top);
+ DockPanel.SetDock(statusBar, Dock.Bottom);
+ root.Children.Add(headerBar);
+ root.Children.Add(statusBar);
+ root.Children.Add(body);
+ Content = root;
+
+ _title.Text = _strings["Title"];
+ _subtitle.Text = _strings["NoFile"];
+ _message.Text = _strings["NoFile"];
+ Closed += (_, _) =>
+ {
+ _tick.Stop();
+ _reload.Stop();
+ };
+ }
+
+ // When and at which end the signals were wrong: "10:55:12 head Tb 1 instead of Pc 1".
+ private string LightsFaultText(Recording recording, LightsFault fault, CultureInfo culture)
+ {
+ var ends = new List();
+ if (fault.HeadWrong)
+ ends.Add(string.Format(culture, _strings["StatLightsHead"], HeadCodes.Name(fault.Head), HeadCodes.Name(fault.HeadRequired)));
+ if (fault.TailWrong)
+ ends.Add(string.Format(culture, _strings["StatLightsTail"], HeadCodes.Name(fault.Tail), HeadCodes.Name(fault.TailRequired)));
+ return $"{Clock(recording.ClockAt(fault.Start).TotalSeconds)} {string.Join(", ", ends)}";
+ }
+
+ private UiKit.Tile Tile(string key)
+ {
+ var tile = new UiKit.Tile(_strings[key]);
+ _tiles.Children.Add(tile);
+ return tile;
+ }
+
+ private static Button MapButton(string text, string tip, Action action)
+ {
+ var button = UiKit.Button(text, tip);
+ button.Width = 28;
+ button.Height = 28;
+ button.Padding = new Thickness(0);
+ button.FontSize = 15;
+ button.HorizontalContentAlignment = HorizontalAlignment.Center;
+ button.VerticalContentAlignment = VerticalAlignment.Center;
+ button.Background = Palette.Panel;
+ button.Click += (_, _) => action();
+ return button;
+ }
+
+ private static void Place(Grid grid, Control control, int row, int column)
+ {
+ Grid.SetRow(control, row);
+ Grid.SetColumn(control, column);
+ grid.Children.Add(control);
+ }
+
+ /// The button for opening another file: the starter opens the one picked in its list.
+ public bool CanOpenFiles
+ {
+ get => _open.IsVisible;
+ set => _open.IsVisible = value;
+ }
+
+ /// Reads what the simulator adds to the file every two seconds.
+ public bool Live
+ {
+ get => _live.IsChecked == true;
+ set => _live.IsChecked = value;
+ }
+
+ public string? FilePath => _path;
+
+ public Recording? Recording => _reader?.Recording;
+
+ public double CurrentTime => _time;
+
+ /// Goes to a moment of the recording, in seconds from its start.
+ public void SeekTo(double t) => SetTime(t);
+
+ /// The moment shown has changed: played on, sought or read further.
+ public event Action? TimeChanged;
+
+ /// Keeps the user's train in the middle of the map.
+ public bool FollowTrain
+ {
+ get => _follow.IsChecked == true;
+ set => _follow.IsChecked = value;
+ }
+
+ /// The timetable beside the map instead of the events.
+ public bool ShowTimetable
+ {
+ get => _tabs.Selected == 1;
+ set => _tabs.Selected = value ? 1 : 0;
+ }
+
+ /// The summary of the driving beside the map instead of the events.
+ public bool SummaryShown
+ {
+ get => _tabs.Selected == 2;
+ set => _tabs.Selected = value ? 2 : 0;
+ }
+
+ private bool Playing
+ {
+ get => _tick.IsEnabled;
+ set
+ {
+ if (value == _tick.IsEnabled)
+ return;
+ if (value)
+ {
+ if (Recording is { } r && _time >= r.Duration - 0.05 && !Live)
+ SetTime(0);
+ _played.Restart();
+ _tick.Start();
+ }
+ else
+ _tick.Stop();
+ _play.Content = UiKit.PlayGlyph(value);
+ ToolTip.SetTip(_play, _strings[value ? "Pause" : "Play"]);
+ }
+ }
+
+ private void ShowWhole()
+ {
+ _follow.IsChecked = false;
+ _map.Fit();
+ _timeline.Fit();
+ }
+
+ /// Opens a black box, read whole on another thread.
+ public async Task OpenAsync(string path)
+ {
+ Playing = false;
+ ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Reading"], Path.GetFileName(path)));
+ try
+ {
+ var reader = await Task.Run(() =>
+ {
+ var r = new BlackBoxReader();
+ r.ReadMore(path);
+ return r;
+ });
+ _reader = reader;
+ _path = path;
+ Title = string.Format(CultureInfo.CurrentCulture, _strings["TitleOf"], Path.GetFileName(path));
+ _map.Recording = reader.Recording;
+ _timeline.Recording = reader.Recording;
+ _events.Recording = reader.Recording;
+ _timetable.Recording = reader.Recording;
+ _cab.Recording = reader.Recording;
+ _message.IsVisible = false;
+ // A file still written opens at its end, where the train is now.
+ SetTime(Live ? reader.Recording.Duration : 0);
+ ShowSummary();
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
+ {
+ ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], path, ex.Message));
+ }
+ }
+
+ private void ShowMessage(string text)
+ {
+ _message.Text = text;
+ _message.IsVisible = true;
+ _subtitle.Text = text;
+ _fileState.Text = "";
+ }
+
+ /// What the simulator added since the last read. Small, so read here, between frames.
+ public void ReadMore()
+ {
+ if (_reader is not { } reader || _path is null || _reading)
+ return;
+ _reading = true;
+ try
+ {
+ bool atEnd = _time >= reader.Recording.Duration - 0.5;
+ if (reader.ReadMore(_path) == 0)
+ return;
+ _events.Refresh();
+ if (atEnd && !Playing)
+ SetTime(reader.Recording.Duration);
+ else
+ SetTime(_time);
+ ShowSummary();
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
+ {
+ ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], _path, ex.Message));
+ }
+ finally
+ {
+ _reading = false;
+ }
+ }
+
+ private async Task PickAsync()
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = _strings["Open"],
+ AllowMultiple = false,
+ FileTypeFilter = new[] { new FilePickerFileType(_strings["FileFilter"]) { Patterns = new[] { "*.blackbox" } } }
+ });
+ if (files.Count > 0 && files[0].TryGetLocalPath() is { } path)
+ await OpenAsync(path);
+ }
+
+ private void Advance()
+ {
+ if (Recording is not { } recording)
+ return;
+ double real = _played.Elapsed.TotalSeconds;
+ _played.Restart();
+ double speed = Speeds[Math.Clamp(_speed.SelectedIndex, 0, Speeds.Length - 1)];
+ double next = _time + real * speed;
+ if (next >= recording.Duration)
+ {
+ next = recording.Duration;
+ // At the end of a file still written it waits for more.
+ if (!Live)
+ Playing = false;
+ }
+ SetTime(next);
+ }
+
+ private void SetTime(double t)
+ {
+ var recording = Recording;
+ double duration = recording?.Duration ?? 0;
+ _time = Math.Clamp(t, 0, duration);
+ _map.Time = _time;
+ _timeline.Time = _time;
+ _cab.Time = _time;
+ _timetable.Time = _time;
+ if (recording is null)
+ return;
+ _clock.Text = recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
+ _events.ShowTime(_time);
+ ShowReadout(null);
+ ShowDelay(recording);
+ TimeChanged?.Invoke(_time);
+ }
+
+ /// How late the user's train was at the moment played, by the clock.
+ private void ShowDelay(Recording recording)
+ {
+ if (StationProgress.DelayAt(recording, _time) is not { } delay)
+ {
+ _delayText.Text = "";
+ return;
+ }
+ var culture = CultureInfo.CurrentCulture;
+ var brush = Palette.Delay(delay.Minutes);
+ _delayText.Text = Math.Abs(delay.Minutes) < 0.5
+ ? _strings["OnTime"]
+ : string.Format(culture, _strings["DelayNow"], string.Format(culture, _strings["Minutes"], delay.Minutes));
+ _delayText.Foreground = brush;
+ ToolTip.SetTip(_delayText, string.Format(culture, _strings[delay.Standing ? "DelayStanding" : "DelayAfter"], delay.Train,
+ StationNames.Display(delay.Station)));
+ }
+
+ /// The user's train at the moment under the pointer of the timeline, or at the moment played.
+ private void ShowReadout(double? hovered)
+ {
+ if (Recording is not { } recording)
+ return;
+ double t = hovered ?? _time;
+ string clock = recording.ClockAt(t).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
+ var culture = CultureInfo.CurrentCulture;
+ if (recording.CabAt(t) is not { } s || t - s.Time > 1.0)
+ {
+ _readout.Text = string.Format(culture, _strings["CursorOut"], clock);
+ return;
+ }
+ if (!s.AiValid)
+ {
+ _readout.Text = string.Format(culture, _strings["CursorNoAi"], clock, s.AbsSpeedKmh);
+ return;
+ }
+ string limit = s.Ai.HasNextLimit
+ ? string.Format(culture, _strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance)
+ : _strings["None"];
+ _readout.Text = string.Format(culture, _strings["Cursor"], clock, s.AbsSpeedKmh,
+ s.Ai.TargetKmh.ToString("0", culture) + " km/h", limit);
+ }
+
+ private void ShowSummary()
+ {
+ if (Recording is not { } recording)
+ return;
+ var summary = recording.Summarize();
+ var culture = CultureInfo.CurrentCulture;
+
+ // The scenery, the train and the vehicle the user started in.
+ string scenery = recording.Info?.Scenery ?? Path.GetFileName(_path) ?? "";
+ _title.Text = Path.GetFileNameWithoutExtension(scenery.TrimStart('$'));
+ var timetable = recording.TimetableAt(_time);
+ string vehicle = recording.Cab.Count > 0 ? recording.Vehicle(recording.Cab[^1].Vehicle)?.Name ?? "" : recording.Info?.StartVehicle ?? "";
+ var parts = new[]
+ {
+ timetable is null ? null : $"{timetable.Category} {timetable.Train}".Trim(),
+ timetable is null || timetable.From.Length + timetable.To.Length == 0 ? null : string.Format(culture, _strings["Relation"], timetable.From, timetable.To),
+ vehicle,
+ recording.Info?.Started.Replace('T', ' ')
+ };
+ _subtitle.Text = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
+
+ _distance.Show($"{summary.DistanceKm:0.0} km");
+ _maxSpeed.Show($"{summary.MaxSpeedKmh:0} km/h");
+ _driven.Show(Clock(summary.DrivenSeconds), string.Format(culture, _strings["StatAutopilotSub"], Clock(summary.AutopilotSeconds)));
+ _overAi.Show(Clock(summary.OverAiSeconds),
+ string.Format(culture, _strings["StatOverAiSub"], Clock(summary.WellOverAiSeconds), DrivingSummary.DefaultTolerance, summary.MaxOverAiKmh),
+ summary.WellOverAiSeconds > 0 ? Palette.Bad : summary.OverAiSeconds > 0 ? Palette.Warn : Palette.Good);
+ _signals.Show(summary.SignalsPassed.ToString(culture), string.Format(culture, _strings["StatSignalsSub"], summary.PassedAtDanger.Count),
+ summary.PassedAtDanger.Count > 0 ? Palette.Bad : null);
+ _stops.IsVisible = recording.Timetables.Count > 0;
+ _stops.Show(summary.Stops.ToString(culture), string.Format(culture, _strings["StatStopsSub"], summary.StopsMissed),
+ summary.StopsMissed > 0 ? Palette.Bad : null);
+ _delay.IsVisible = summary.MaxDelayMinutes is not null;
+ if (summary.MaxDelayMinutes is { } delay)
+ _delay.Show(string.Format(culture, _strings["Minutes"], delay),
+ summary.MaxDepartureDelayMinutes is { } departure
+ ? string.Format(culture, _strings["StatDelaySub"], string.Format(culture, _strings["Minutes"], departure))
+ : null,
+ Palette.Delay(delay));
+ _platform.IsVisible = summary.Stops > 0;
+ _platform.Show(summary.OffPlatform.Count.ToString(culture),
+ summary.OffPlatform.Count > 0
+ ? string.Join(" · ", summary.OffPlatform.Select(p => string.Format(culture, _strings["StatPlatformAt"], p.Station, p.Metres)))
+ : _strings["StatPlatformNone"],
+ summary.OffPlatform.Count > 0 ? Palette.Bad : Palette.Good);
+ _lights.IsVisible = summary.LightsJudgedSeconds > 0;
+ _lights.Show(summary.WrongLightsSeconds > 0 ? Clock(summary.WrongLightsSeconds) : _strings["StatLightsRight"],
+ summary.LightsFaults.Count > 0
+ ? string.Join(" · ", summary.LightsFaults.Take(3).Select(f => LightsFaultText(recording, f, culture)))
+ + (summary.LightsFaults.Count > 3 ? " …" : "")
+ : string.Format(culture, _strings["StatLightsSub"], Clock(summary.LightsJudgedSeconds)),
+ summary.WrongLightsSeconds > 0 ? Palette.Bad : Palette.Good);
+ _horn.IsVisible = summary.HornPoints > 0;
+ _horn.Show($"{summary.HornPoints - summary.HornsMissed}/{summary.HornPoints}", string.Format(culture, _strings["StatHornSub"], summary.HornsMissed),
+ summary.HornsMissed > 0 ? Palette.Bad : Palette.Good);
+ _acceleration.Show($"+{summary.MaxAcceleration:0.00} / −{summary.MaxDeceleration:0.00}",
+ string.Format(culture, _strings["StatAccelerationSub"], summary.MaxLateralAcceleration));
+ _security.Show(summary.SecurityBrakings.ToString(culture), null, summary.SecurityBrakings > 0 ? Palette.Bad : Palette.Good);
+ _state.Show(Clock(recording.Duration), _strings[recording.Complete ? "StatComplete" : "StatIncomplete"],
+ recording.Complete ? null : Palette.Warn);
+ _fileState.Text = $"{Path.GetFileName(_path)} · {Clock(recording.Duration)} · {_strings[recording.Complete ? "StatComplete" : "StatIncomplete"]}";
+ }
+
+ private static string Clock(double seconds)
+ {
+ var span = TimeSpan.FromSeconds(Math.Round(seconds));
+ return span.TotalHours >= 1
+ ? span.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
+ : span.ToString(@"m\:ss", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/Eu07.BlackBox.Views/CabPanel.cs b/Eu07.BlackBox.Views/CabPanel.cs
new file mode 100644
index 0000000..f896579
--- /dev/null
+++ b/Eu07.BlackBox.Views/CabPanel.cs
@@ -0,0 +1,567 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// The cab of the user's train at the moment played, for checking what the driver did: the
+/// speed against the AI's, the accelerations, the controllers, the brakes (the pressures as
+/// gauges, the setting), the traction as fits what drives the vehicle (the voltage and the
+/// current of an electric one, the oil and the temperatures of a diesel), the lights at the
+/// head and the tail of the train with what they are to show, and a lamp for every device in
+/// groups (power, driving, safety, doors), the alerter (CA) and the cab signalling (SHP) apart.
+///
+public sealed class CabPanel : Control
+{
+ private const double Pad = 14;
+ private const double LampRow = 14;
+ private const int DoorColumns = 2;
+ private const double DriveRow = 34;
+
+ private Recording? _recording;
+ private double _time;
+ private int _measured = -1;
+ private int _controllerTop = 1;
+ private readonly HashSet _powered = new();
+ private readonly HashSet _feedPipe = new();
+ private readonly HashSet _integrated = new();
+ private readonly HashSet _hydraulic = new();
+
+ public CabPanel()
+ {
+ ClipToBounds = true;
+ Height = 696;
+ }
+
+ 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, right = size.Width - 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;
+ var extra = s.Extra;
+
+ // The vehicle at the left; who drives, from which cab, at the right.
+ double chipX = right;
+ if (s.CabEnd != 0)
+ chipX -= ChipLeft(context, Strings[s.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], Palette.Text, chipX, 4) + 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, 4) + 10;
+ using (context.PushClip(new Rect(Pad, 0, Math.Max(0, chipX - Pad), 28)))
+ context.DrawText(MapView.Text(recording.Vehicle(s.Vehicle)?.Name ?? "", Palette.Text, 12, FontWeight.SemiBold), new Point(Pad, 6));
+
+ // The speed, large, what the AI would do under it, the accelerations at the right.
+ 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, 36, FontWeight.SemiBold);
+ context.DrawText(speed, new Point(Pad, 24));
+ context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, 24 + speed.Height - 21));
+ double gaugeX = Pad + Math.Max(150, width * 0.42);
+ if (s.AiValid)
+ {
+ using (context.PushClip(new Rect(Pad, 76, gaugeX - Pad - 8, 40)))
+ {
+ context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12), new Point(Pad, 76));
+ 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, 93));
+ }
+ }
+ double gaugeWidth = right - gaugeX;
+ Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, 34, gaugeWidth);
+ Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, 70, gaugeWidth);
+
+ // The controllers.
+ double top = Section(context, Strings["CabSectionControls"], Pad, 118, right);
+ double column = width / 4;
+ Value(context, Strings["CabController"], s.MainController.ToString(culture), Pad, top);
+ Steps(context, s.MainController, _controllerTop, Pad, top + 34, column - 14);
+ if (extra is { } handle && _integrated.Contains(s.Vehicle))
+ Value(context, Strings["CabIntegrated"], (handle.IntegratedController * 100).ToString("+0;−0;0", culture) + " %", Pad + column, top);
+ else
+ 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, the setting (G, P, R) at the right of the title.
+ string? setting = extra is { } e && (e.BrakeDelay | e.BrakeMode) != 0
+ ? string.Format(culture, Strings["CabBrakeSetting"], string.Join(" · ", new[] { BrakeSetting(e.BrakeDelay), BrakeMode(e.BrakeMode) }.Where(t => t.Length > 0)))
+ : null;
+ top = Section(context, Strings["CabSectionBrakes"], Pad, 180, right, setting);
+ const double gauge = 17;
+ Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
+ double y = top + gauge;
+ if (extra is { } x1)
+ {
+ // what the driver's brake valve shows: its equalizing reservoir, the FV4a's time reservoir
+ Gauge(context, Strings["CabDriverValve"], x1.DriverValve, 6, null, $"{x1.DriverValve:0.00} bar", Palette.Target, Pad, y, width);
+ y += gauge;
+ }
+ Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
+ y += gauge;
+ if (extra is { } x3)
+ {
+ // the most braked vehicle of the train
+ Gauge(context, Strings["CabConsistCylinder"], x3.ConsistBrakeCylinder, 6, null, $"{x3.ConsistBrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
+ y += gauge;
+ }
+ Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, y, width);
+ y += gauge;
+ if (extra is { } x2)
+ {
+ bool has = _feedPipe.Contains(s.Vehicle);
+ Gauge(context, Strings["CabFeedPipe"], has ? x2.FeedPipe : 0, 10, null, has ? $"{x2.FeedPipe:0.0} bar" : "—", Palette.Controller, Pad, y, width);
+ y += gauge;
+ }
+ Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, y, width);
+
+ // The traction at the left, as fits what drives the vehicle; the lights of both ends of the train at the right.
+ double half = Pad + width / 2;
+ double driveTop = y + gauge + 4;
+ top = Section(context, Strings["CabSectionDrive"], Pad, driveTop, half - 10);
+ Section(context, Strings["CabLights"], half + 4, driveTop, right);
+ DrawDrive(context, s, top, half - 10 - Pad, culture);
+ double row = top;
+ foreach (var (key, lights, equipped, required, isRight) in new[]
+ {
+ ("CabLightsHead", s.LightsHead, s.EquippedHead, HeadCodes.RequiredHead(s), HeadCodes.HeadRight(s)),
+ ("CabLightsTail", s.LightsTail, s.EquippedTail, HeadCodes.RequiredTail(s), HeadCodes.TailRight(s))
+ })
+ {
+ DrawFace(context, lights, new Point(half + 4 + 17, row + 15));
+ // the end, and under it the signal it shows: green as it is to be (with the lamps the vehicle has),
+ // red otherwise with the one it is to show
+ var shown = HeadCodes.Of(lights);
+ var text = HeadCodes.NoLamps(equipped) ? Strings["CabLightsNone"]
+ : isRight == false && required is { } want
+ ? string.Format(culture, Strings["CabLightsWant"], HeadCodes.Name(shown), HeadCodes.Name(want))
+ : isRight == true && required is { } met ? HeadCodes.Name(met) : HeadCodes.Name(shown);
+ var brush = isRight is null ? Palette.DimText : isRight.Value ? Palette.Good : Palette.Bad;
+ using (context.PushClip(new Rect(half + 44, row, right - half - 44, 28)))
+ {
+ context.DrawText(MapView.Text(Strings[key], Palette.DimText, 10), new Point(half + 46, row));
+ context.DrawText(MapView.Text(text, brush, 12, FontWeight.SemiBold), new Point(half + 46, row + 11));
+ }
+ row += 27;
+ }
+
+ // The devices, in three groups side by side: lit as their lamps are in the cab, the ones off faint.
+ top = Section(context, Strings["CabSectionDevices"], Pad, top + 3 * DriveRow + 6, right);
+ var groups = Devices(s, culture);
+ var widths = new double[groups.Length];
+ for (int g = 0; g < groups.Length; g++)
+ {
+ widths[g] = MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold).Width;
+ foreach (var (text, _) in groups[g].Lamps)
+ widths[g] = Math.Max(widths[g], 14 + MapView.Text(text, Palette.Text, 10.5).Width);
+ }
+ // each group as wide as its longest name, what is left shared between them
+ double spare = (width - widths.Sum()) / groups.Length;
+ double x = Pad, bottom = top;
+ for (int g = 0; g < groups.Length; g++)
+ {
+ double groupWidth = Math.Max(20, widths[g] + spare);
+ using (context.PushClip(new Rect(x, top, groupWidth, size.Height - top)))
+ {
+ context.DrawText(MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold), new Point(x, top));
+ double lampY = top + 16;
+ foreach (var (text, brush) in groups[g].Lamps)
+ {
+ Lamp(context, text, brush, x, lampY);
+ lampY += LampRow;
+ }
+ bottom = Math.Max(bottom, lampY);
+ }
+ x += groupWidth;
+ }
+
+ // The doors under them, in two columns: open, permitted, the departure signal, the steps and the lock.
+ top = Math.Max(bottom, top + 16 + 9 * LampRow) + 6;
+ context.DrawText(MapView.Text(Strings["CabGroupDoors"], Palette.DimText, 10, FontWeight.SemiBold), new Point(Pad, top));
+ var doors = Doors(s);
+ double doorColumn = width / DoorColumns;
+ for (int i = 0; i < doors.Length; i++)
+ {
+ double doorX = Pad + i % DoorColumns * doorColumn;
+ using (context.PushClip(new Rect(doorX, top, doorColumn, size.Height - top)))
+ Lamp(context, doors[i].Text, doors[i].Lit, doorX, top + 16 + i / DoorColumns * LampRow);
+ }
+ }
+
+ ///
+ /// The traction, in two columns, as fits what drives the vehicle: the voltage, the current, the revolutions,
+ /// the power and the cruise control of an electric one; the revolutions, the current (or the clutch, or the
+ /// torque converter), the oil pressure and the temperatures of a diesel. A recording made before the engine
+ /// was written: those of an electric one it had then, no voltage for a vehicle which never had any.
+ ///
+ private void DrawDrive(DrawingContext context, CabSample s, double top, double width, CultureInfo culture)
+ {
+ var cells = new List<(string Label, string Value, IBrush? Brush)>();
+ void Add(string key, string value, IBrush? brush = null) => cells.Add((Strings[key], value, brush));
+
+ string voltage = _powered.Contains(s.Vehicle) ? $"{s.Voltage:0} V" : "—";
+ switch (s.Extra?.Engine)
+ {
+ case null:
+ Add("CabVoltage", voltage);
+ Add("CabCurrent", $"{s.MotorCurrent:0} A");
+ Add("CabRpm", $"{s.EngineRpm:0}");
+ break;
+ case EngineKind.Electric:
+ case EngineKind.ElectricInduction:
+ {
+ var e = s.Extra.Value;
+ Add("CabVoltage", voltage);
+ Add("CabCurrent", $"{s.MotorCurrent:0} A");
+ Add("CabRpm", $"{s.EngineRpm:0}");
+ Add("CabPower", $"{e.EnginePower:0} kW");
+ Add("CabSpeedControl", s.Has(CabDevices.SpeedControl) ? $"{e.SpeedControlKmh:0} km/h" : "—");
+ break;
+ }
+ case EngineKind.DieselElectric:
+ case EngineKind.Diesel:
+ {
+ var e = s.Extra.Value;
+ Add("CabRpm", $"{s.EngineRpm:0}");
+ if (e.Engine == EngineKind.DieselElectric)
+ Add("CabCurrent", $"{s.MotorCurrent:0} A");
+ else if (_hydraulic.Contains(s.Vehicle))
+ Add("CabConverterFill", $"{e.ConverterFill * 100:0} %");
+ else
+ Add("CabClutch", $"{e.Clutch * 100:0} %");
+ Add("CabOilPressure", string.Format(culture, "{0:0.00} MPa", e.OilPressure));
+ // the temperatures, warm when the engine is too hot
+ var heat = s.Has(CabDevices.EngineHot) ? Palette.Bad : null;
+ Add("CabWater", $"{e.WaterTemperature:0} °C", heat);
+ Add("CabOil", $"{e.OilTemperature:0} °C", heat);
+ Add("CabEngine", $"{e.EngineTemperature:0} °C", heat);
+ break;
+ }
+ case EngineKind.Steam:
+ Add("CabRpm", $"{s.EngineRpm:0}");
+ break;
+ default:
+ context.DrawText(MapView.Text(Strings["CabNoDrive"], Palette.DimText, 11), new Point(Pad, top + 2));
+ return;
+ }
+ double column = width / 2;
+ for (int i = 0; i < cells.Count; i++)
+ {
+ double x = Pad + i % 2 * column, y = top + i / 2 * DriveRow;
+ using (context.PushClip(new Rect(x, y, column - 4, DriveRow)))
+ Value(context, cells[i].Label, cells[i].Value, x, y, cells[i].Brush);
+ }
+ }
+
+ /// The setting of the brakes from its bits: G, P, R, R+Mg.
+ public static string BrakeSetting(byte bits)
+ {
+ var parts = new List();
+ if ((bits & 1) != 0)
+ parts.Add("G");
+ if ((bits & 2) != 0)
+ parts.Add("P");
+ if ((bits & 4) != 0)
+ parts.Add("R");
+ if ((bits & 8) != 0)
+ parts.Add("Mg");
+ return string.Join("+", parts);
+ }
+
+ /// The mode of the brakes from its bits: PS, PN, EP, MED.
+ public static string BrakeMode(byte bits)
+ {
+ var parts = new List();
+ if ((bits & 1) != 0)
+ parts.Add("PS");
+ if ((bits & 2) != 0)
+ parts.Add("PN");
+ if ((bits & 4) != 0)
+ parts.Add("EP");
+ if ((bits & 8) != 0)
+ parts.Add("MED");
+ return string.Join("+", parts);
+ }
+
+ /// The title of a part of the panel, with a line from it to the right (and a note at its end); the top of the part returned.
+ private static double Section(DrawingContext context, string title, double x, double y, double right, string? note = null)
+ {
+ var text = MapView.Text(title.ToUpper(CultureInfo.CurrentCulture), Palette.DimText, 9, FontWeight.SemiBold);
+ context.DrawText(text, new Point(x, y));
+ double end = right;
+ if (note is not null)
+ {
+ var noted = MapView.Text(note, Palette.Text, 10, FontWeight.SemiBold);
+ end = right - noted.Width - 8;
+ context.DrawText(noted, new Point(right - noted.Width, y + (text.Height - noted.Height) / 2));
+ }
+ double line = Math.Round(y + text.Height / 2) + 0.5;
+ if (x + text.Width + 8 < end)
+ context.DrawLine(new Pen(Palette.Grid, 1), new Point(x + text.Width + 8, line), new Point(end, line));
+ return y + 17;
+ }
+
+ ///
+ /// What is the same all the recording long for each vehicle: the highest step of the controller used, for
+ /// its bar, and which vehicles ever had a voltage, a main reservoir pipe, an integrated controller moved off
+ /// nought and a torque converter filled, to show the field shunting, the clutch or none for the others.
+ ///
+ private void Measure(Recording recording)
+ {
+ if (_measured == recording.Revision)
+ return;
+ int top = 1;
+ _powered.Clear();
+ _feedPipe.Clear();
+ _integrated.Clear();
+ _hydraulic.Clear();
+ foreach (var sample in recording.Cab)
+ {
+ top = Math.Max(top, sample.MainController);
+ if (sample.Voltage > 1)
+ _powered.Add(sample.Vehicle);
+ if (sample.Extra is not { } e)
+ continue;
+ if (e.FeedPipe > 0.1f)
+ _feedPipe.Add(sample.Vehicle);
+ if (Math.Abs(e.IntegratedController) > 0.01f)
+ _integrated.Add(sample.Vehicle);
+ if (e.ConverterFill > 0.01f)
+ _hydraulic.Add(sample.Vehicle);
+ }
+ _controllerTop = top;
+ _measured = recording.Revision;
+ }
+
+ private static void Value(DrawingContext context, string label, string value, double x, double y, IBrush? brush = null)
+ {
+ context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
+ context.DrawText(MapView.Text(value, brush ?? Palette.Text, 14, FontWeight.SemiBold), new Point(x, y + 13));
+ }
+
+ /// The steps of the controller as a row of cells, lit up to the one it stands at.
+ 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);
+ }
+
+ /// A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.
+ 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));
+ }
+
+ /// A value either way of nought: a bar from the middle, to the right above it.
+ 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));
+ }
+
+ /// A chip ending at x, its width returned.
+ 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;
+ }
+
+ /// The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.
+ 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);
+ Light(context, center + new Vector(0, -8), lights.HasFlag(Lights.HeadlightUpper), false, false);
+ Light(context, center + new Vector(-10, 1), lights.HasFlag(Lights.HeadlightLeft) || lights.HasFlag(Lights.AuxiliaryLeft),
+ lights.HasFlag(Lights.RedMarkerLeft) || rearEnd, lights.HasFlag(Lights.HighBeamLeft));
+ Light(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 Light(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);
+ }
+
+ /// A device's lamp and its name: lit in its colour, the name bright; off, both faint.
+ private static void Lamp(DrawingContext context, string text, IBrush? lit, double x, double y)
+ {
+ var center = new Point(x + 4, y + 7);
+ 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);
+ context.DrawText(MapView.Text(text, lit is null ? Palette.FaintText : Palette.Text, 10.5), new Point(x + 14, y));
+ }
+
+ ///
+ /// Every device but the doors, with the colour of its lamp if it was on, in the groups the panel shows. A
+ /// diesel has its pumps where an electric vehicle has its pantographs; a recording made before the engine
+ /// was written has the devices it had then.
+ ///
+ private (string Title, (string Text, IBrush? Lit)[] Lamps)[] Devices(CabSample s, CultureInfo culture)
+ {
+ (string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
+ (string, IBrush?) Flag(string key, CabFlags flag, IBrush colour) => (Strings[key], (s.Flags & flag) != 0 ? colour : null);
+ IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
+ var extra = s.Extra;
+ bool diesel = extra is { Engine: EngineKind.Diesel or EngineKind.DieselElectric };
+ (string, IBrush?) Horn()
+ {
+ if (!s.Has(CabDevices.Horn))
+ return (Strings["DevHorn"], null);
+ string? tone = extra?.Horn switch
+ {
+ 1 => Strings["HornLow"],
+ 2 => Strings["HornHigh"],
+ 3 => Strings["HornBoth"],
+ _ => null
+ };
+ return (tone is null ? Strings["DevHorn"] : string.Format(culture, Strings["DevHornTone"], tone), Palette.LampWarning);
+ }
+ bool electric = extra is null or { Engine: EngineKind.Electric or EngineKind.ElectricInduction };
+
+ var power = new List<(string, IBrush?)> { On("DevBattery", CabDevices.Battery, Palette.LampOn), On("DevMainBreaker", CabDevices.MainBreaker, Palette.LampOn) };
+ if (electric)
+ {
+ power.Add(On("DevPantographFront", CabDevices.PantographFront, Palette.LampOn));
+ power.Add(On("DevPantographRear", CabDevices.PantographRear, Palette.LampOn));
+ }
+ if (diesel)
+ {
+ power.Add(On("DevFuelPump", CabDevices.FuelPump, Palette.LampOn));
+ power.Add(On("DevOilPump", CabDevices.OilPump, Palette.LampOn));
+ }
+ power.Add(On("DevConverter", CabDevices.Converter, Palette.LampOn));
+ power.Add(On("DevCompressor", CabDevices.Compressor, Palette.LampOn));
+ power.Add(On("DevLineContactors", CabDevices.LineContactors, Palette.LampOn));
+ if (extra is not null)
+ power.Add(On("DevMotorBlowers", CabDevices.MotorBlowers, Palette.LampOn));
+ power.Add(On("DevHeating", CabDevices.Heating, Palette.LampOn));
+
+ // the radio with its channel
+ var radio = extra is { RadioChannel: > 0 } r
+ ? (string.Format(culture, Strings["DevRadioChannel"], r.RadioChannel), s.Has(CabDevices.Radio) ? Palette.LampOn : null)
+ : On("DevRadio", CabDevices.Radio, Palette.LampOn);
+ var driving = new List<(string, IBrush?)>
+ {
+ On("DevShuntMode", CabDevices.ShuntMode, Palette.LampOn), radio,
+ On("DevSanding", CabDevices.Sanding, Palette.LampWarning), Horn(),
+ On("DevSpringBrake", CabDevices.SpringBrake, Palette.LampWarning)
+ };
+ if (extra is not null)
+ {
+ driving.Add((Strings["DevHandBrake"], extra.Value.HandBrake > 0 ? Palette.LampWarning : null));
+ driving.Add(On("DevBrakeReleaser", CabDevices.BrakeReleaser, Palette.LampWarning));
+ driving.Add(On("DevSpeedControl", CabDevices.SpeedControl, Palette.LampOn));
+ }
+
+ var safety = new List<(string, IBrush?)>();
+ if (s.Security != SecurityFlags.None || s.CabEnd != 0)
+ {
+ // A recording which tells them apart.
+ safety.Add((Strings["DevVigilance"], Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance)));
+ safety.Add((Strings["DevCabSignal"], Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal)));
+ safety.Add((Strings["DevSecurityBraking"], Security(SecurityFlags.Braking, Palette.LampAlarm)));
+ }
+ else
+ safety.Add((Strings["DevAlerter"], (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
+ : (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.LampWarning : null));
+ safety.Add(Flag("DevWheelSlip", CabFlags.WheelSlip, Palette.LampAlarm));
+ safety.Add(On("DevOverloadRelay", CabDevices.OverloadRelay, Palette.LampAlarm));
+ safety.Add(Flag("DevRadioStop", CabFlags.RadioStop, Palette.LampAlarm));
+ safety.Add(Flag("DevAlarmChain", CabFlags.AlarmChain, Palette.LampAlarm));
+ if (diesel)
+ safety.Add(On("DevEngineHot", CabDevices.EngineHot, Palette.LampAlarm));
+ return [("CabGroupPower", power.ToArray()), ("CabGroupDriving", driving.ToArray()), ("CabGroupSafety", safety.ToArray())];
+ }
+
+ /// The doors of the vehicle the user is in, in rows of two: open, the lock, the permits, the steps, the departure signal.
+ private (string Text, IBrush? Lit)[] Doors(CabSample s)
+ {
+ (string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
+ var doors = new List<(string, IBrush?)>
+ {
+ On("DevDoorsLeft", CabDevices.DoorsLeft, Palette.LampWarning), On("DevDoorsRight", CabDevices.DoorsRight, Palette.LampWarning)
+ };
+ if (s.Extra is null)
+ return doors.ToArray();
+ doors.Add(On("DevDoorLock", CabDevices.DoorLock, Palette.LampOn));
+ doors.Add(On("DevDoorPermitLeft", CabDevices.DoorPermitLeft, Palette.LampOn));
+ doors.Add(On("DevDoorPermitRight", CabDevices.DoorPermitRight, Palette.LampOn));
+ doors.Add(On("DevDoorsLocked", CabDevices.DoorsLocked, Palette.LampOn));
+ doors.Add(On("DevDoorStep", CabDevices.DoorStep, Palette.LampOn));
+ doors.Add(On("DevDepartureSignal", CabDevices.DepartureSignal, Palette.LampWarning));
+ return doors.ToArray();
+ }
+}
diff --git a/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj b/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
new file mode 100644
index 0000000..53efe37
--- /dev/null
+++ b/Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0
+ enable
+ true
+ true
+ Eu07.BlackBox.Views
+
+
+
+
+
+
+
+
+
+
diff --git a/Eu07.BlackBox.Views/EventList.cs b/Eu07.BlackBox.Views/EventList.cs
new file mode 100644
index 0000000..be3b07d
--- /dev/null
+++ b/Eu07.BlackBox.Views/EventList.cs
@@ -0,0 +1,382 @@
+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;
+
+/// One line of the list of events.
+public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
+
+///
+/// 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.
+///
+public sealed class EventList : ListBox
+{
+ private Recording? _recording;
+ private int _shownRevision = -1;
+ private List _events = new();
+ private bool _picking;
+
+ public event Action? 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 Events => _events;
+
+ /// Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.
+ 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;
+ }
+
+ /// The event at or just before t picked, without going to it.
+ 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;
+ }
+
+ /// The events of the user's train, in the order they happened.
+ public static List Collect(Recording recording, IBlackBoxStrings strings)
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var events = new List();
+ 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);
+ AddCabChanges(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 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 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;
+ }
+ }
+ }
+
+ /// The camera of the user going to another mode: the cab, outside, free.
+ private static void AddCameras(Recording recording, IBlackBoxStrings strings, List 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"
+ };
+
+ ///
+ /// The switches the driver worked, as the cab tells them: what came on and went off. Not the compressor and the
+ /// motor fans, which go on and off by themselves.
+ ///
+ private static readonly (CabDevices Device, string Key)[] Switched =
+ [
+ (CabDevices.MainBreaker, "DevMainBreaker"), (CabDevices.PantographFront, "DevPantographFront"),
+ (CabDevices.PantographRear, "DevPantographRear"), (CabDevices.FuelPump, "DevFuelPump"), (CabDevices.OilPump, "DevOilPump"),
+ (CabDevices.Converter, "DevConverter"),
+ (CabDevices.Heating, "DevHeating"), (CabDevices.Radio, "DevRadio"), (CabDevices.ShuntMode, "DevShuntMode"),
+ (CabDevices.SpringBrake, "DevSpringBrake"), (CabDevices.BrakeReleaser, "DevBrakeReleaser"),
+ (CabDevices.DoorPermitLeft, "DevDoorPermitLeft"), (CabDevices.DoorPermitRight, "DevDoorPermitRight"),
+ (CabDevices.DoorStep, "DevDoorStep"), (CabDevices.DoorLock, "DevDoorLock"), (CabDevices.SpeedControl, "DevSpeedControl")
+ ];
+
+ ///
+ /// What the driver did in the cab, from one look to the next in the same vehicle: the switches on and off, the
+ /// doors opened and closed, the departure signal, the hand brake, the brakes' setting, the radio's channel, the
+ /// speed the cruise control is set to; and, as faults, moving with the doors open and the engine overheating.
+ ///
+ private static void AddCabChanges(Recording recording, IBlackBoxStrings strings, List events, CultureInfo culture)
+ {
+ CabSample? last = null;
+ bool movingOpen = false;
+ foreach (var s in recording.Cab)
+ {
+ if (last is not { } b || b.Vehicle != s.Vehicle)
+ {
+ last = s;
+ movingOpen = false;
+ continue;
+ }
+ bool Came(CabDevices device) => !b.Has(device) && s.Has(device);
+ bool Went(CabDevices device) => b.Has(device) && !s.Has(device);
+ foreach (var (device, key) in Switched)
+ {
+ if (Came(device))
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOn"], strings[key]), Palette.LampOn, false));
+ else if (Went(device))
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOff"], strings[key]), Palette.FaintText, false));
+ }
+ foreach (var (device, open, closed) in new[] { (CabDevices.DoorsLeft, "DevDoorsLeft", "EventDoorsLeftClosed"), (CabDevices.DoorsRight, "DevDoorsRight", "EventDoorsRightClosed") })
+ {
+ if (Came(device))
+ events.Add(new BlackBoxEvent(s.Time, strings[open], Palette.LampWarning, false));
+ else if (Went(device))
+ events.Add(new BlackBoxEvent(s.Time, strings[closed], Palette.FaintText, false));
+ }
+ if (Came(CabDevices.DepartureSignal))
+ events.Add(new BlackBoxEvent(s.Time, strings["DevDepartureSignal"], Palette.LampWarning, false));
+ if (Came(CabDevices.EngineHot))
+ events.Add(new BlackBoxEvent(s.Time, strings["DevEngineHot"], Palette.Bad, true));
+ // moving with the doors open: once, until they are closed or the train stands
+ bool doorsOpen = s.Has(CabDevices.DoorsLeft) || s.Has(CabDevices.DoorsRight);
+ if (doorsOpen && s.AbsSpeedKmh > 2 && !movingOpen)
+ {
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventMovingDoorsOpen"], s.AbsSpeedKmh), Palette.Bad, true));
+ movingOpen = true;
+ }
+ else if (!doorsOpen || s.AbsSpeedKmh < 0.5)
+ movingOpen = false;
+ if (b.Extra is { } x && s.Extra is { } y)
+ {
+ if (x.HandBrake <= 0 && y.HandBrake > 0)
+ events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOn"], Palette.LampWarning, false));
+ else if (x.HandBrake > 0 && y.HandBrake <= 0)
+ events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOff"], Palette.FaintText, false));
+ if (x.BrakeDelay != y.BrakeDelay || x.BrakeMode != y.BrakeMode)
+ {
+ string setting = string.Join(" · ", new[] { CabPanel.BrakeSetting(y.BrakeDelay), CabPanel.BrakeMode(y.BrakeMode) }.Where(t => t.Length > 0));
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventBrakeSetting"], setting), Palette.Brakes, false));
+ }
+ if (x.RadioChannel != y.RadioChannel && y.RadioChannel > 0)
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventRadioChannel"], y.RadioChannel), Palette.LampOn, false));
+ // the speed set, while the cruise control holds one
+ if (s.Has(CabDevices.SpeedControl) && (Came(CabDevices.SpeedControl) || Math.Abs(x.SpeedControlKmh - y.SpeedControlKmh) >= 1))
+ events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventSpeedControlSet"], y.SpeedControlKmh), Palette.Target, false));
+ }
+ last = s;
+ }
+ }
+
+ private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List 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));
+ }
+ }
+
+ /// What a coupling joins, in words.
+ public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
+ {
+ var parts = new List();
+ 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);
+ }
+}
diff --git a/Eu07.BlackBox.Views/MapView.cs b/Eu07.BlackBox.Views/MapView.cs
new file mode 100644
index 0000000..d1d0f23
--- /dev/null
+++ b/Eu07.BlackBox.Views/MapView.cs
@@ -0,0 +1,766 @@
+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;
+
+///
+/// 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.
+///
+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;
+
+ /// The user moved the map away from the train: following is off.
+ 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();
+ }
+ }
+
+ /// Keeps the user's train in the middle.
+ 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();
+
+ /// Closer by the factor given, about the middle.
+ public void ZoomBy(double factor)
+ {
+ _scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
+ InvalidateVisual();
+ }
+
+ /// The map as a whole in view.
+ 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 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);
+ }
+
+ /// The way the user's train went, a geometry for each judgement of its speed.
+ private void BuildTrail(Recording recording)
+ {
+ var lines = new List>[_trail.Length];
+ for (int i = 0; i < lines.Length; i++)
+ lines[i] = new List>();
+ 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 { 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ // 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(StringComparer.OrdinalIgnoreCase);
+ var taken = new List();
+ // 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));
+ }
+ }
+
+ /// The horn points (W6): a diamond, green where the user sounded the horn, red where not.
+ 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));
+ }
+ }
+
+ internal 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;
+ }
+
+ /// Where the user looked from, when not from the cab: a dot with a wedge of where it looked.
+ 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);
+ }
+
+ /// Where the user's train passed signals; those at danger ringed red.
+ 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())
+ {
+ if (passing.Time > _time)
+ continue;
+ var p = ToScreen(passing.Position.X, passing.Position.Z);
+ if (!Visible(p))
+ continue;
+ if (passing.AtDanger)
+ context.DrawEllipse(null, danger, p, 10, 10);
+ else
+ 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);
+ }
+
+ /// The head of the user's train between its samples, ten a second, on top of everything.
+ 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));
+ }
+
+ /// Where the label of the user's train goes, by its mark.
+ 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, string.Format(CultureInfo.CurrentCulture, Strings["LegendOver"], DrivingSummary.DefaultTolerance)),
+ (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));
+ }
+ // Each part under a title of its own, in the text's colour: the dim one is hard to read on the map.
+ var title = Text(Strings["LegendTitle"], Palette.DimText, 11);
+ var marksTitle = Text(Strings["LegendMarks"], Palette.DimText, 11);
+ var texts = rows.Select(row => Text(row.Text, Palette.Text, 12)).Concat(marks.Select(m => Text(m.Text, Palette.Text, 12))).ToList();
+ const double line = 19, heading = 18, gap = 8;
+ double width = Math.Max(texts.Max(t => t.Width) + 44, Math.Max(title.Width, marks.Count > 0 ? marksTitle.Width : 0) + 24);
+ double height = 10 + heading + rows.Length * line + (marks.Count > 0 ? gap + heading + marks.Count * line : 0) + 6;
+ var box = new Rect(10, size.Height - height - 10, width, height);
+ context.DrawRectangle(Palette.Faded(Palette.Card, 0.96), new Pen(Palette.CardBorder, 1), box, 3, 3);
+ double y = box.Top + 8;
+ context.DrawText(title, new Point(box.Left + 12, y));
+ y += heading;
+ for (int i = 0; i < rows.Length; i++, y += line)
+ {
+ context.DrawLine(new Pen(rows[i].Brush, 5, lineCap: PenLineCap.Round), new Point(box.Left + 13, y + 9), new Point(box.Left + 27, y + 9));
+ context.DrawText(texts[i], new Point(box.Left + 36, y));
+ }
+ if (marks.Count > 0)
+ {
+ y += gap;
+ context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y - gap / 2), new Point(box.Right - 10, y - gap / 2));
+ context.DrawText(marksTitle, new Point(box.Left + 12, y));
+ y += heading;
+ }
+ for (int i = 0; i < marks.Count; i++, y += line)
+ {
+ var c = new Point(box.Left + 20, y + 9);
+ 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 + 36, y));
+ }
+ }
+
+ /// The name of the signal or the vehicle under the pointer.
+ 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));
+ }
+
+ /// An aspect as the speeds it gives: stop, a speed, or go.
+ 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();
+ }
+}
diff --git a/Eu07.BlackBox.Views/Palette.cs b/Eu07.BlackBox.Views/Palette.cs
new file mode 100644
index 0000000..91319b3
--- /dev/null
+++ b/Eu07.BlackBox.Views/Palette.cs
@@ -0,0 +1,113 @@
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// 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.
+///
+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");
+ /// The headers of the panels, as the group boxes of the starter.
+ public static readonly IBrush Header = Brush("#2A3036");
+ /// The green of what is picked or on: bright for lines and text, deep for fills.
+ 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");
+
+ /// A delay: green on time, yellow a little late, red late, blue early.
+ 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
+ };
+
+ /// What a signal shows: the colour of its main light, as the in-game map draws it.
+ 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;
+ }
+
+ /// A colour seen through: for fills under lines and glows.
+ 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();
+}
diff --git a/Eu07.BlackBox.Views/TimelineView.cs b/Eu07.BlackBox.Views/TimelineView.cs
new file mode 100644
index 0000000..6105c5a
--- /dev/null
+++ b/Eu07.BlackBox.Views/TimelineView.cs
@@ -0,0 +1,704 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace Eu07.BlackBox.Views;
+
+///
+/// The user's train over the time of the recording, by the clock of the simulation: its
+/// speed, the speed the AI would hold and the next limit it saw, below them bands of who
+/// drove and how against the AI, of the controller, the brakes, the alerter (CA), the cab
+/// signalling (SHP) and the camera, and marks of the commands, the signals passed, the
+/// stations, the horn points and the couplings. A click goes to its moment; the wheel zooms
+/// the time, dragging moves it.
+///
+public sealed class TimelineView : Control
+{
+ private const double Left = 56;
+ private const double Right = 12;
+ private const double Top = 36;
+ 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 Point? _pointer;
+ private int _measured = -1;
+ private Recording? _measuredOf;
+ private double _speedTop = 40;
+ private int _controllerTop = 1;
+
+ /// A moment was clicked.
+ public event Action? Seek;
+
+ /// The moment under the pointer, null once it leaves.
+ public event Action? Hovered;
+
+ public TimelineView()
+ {
+ ClipToBounds = true;
+ MinHeight = 160;
+ }
+
+ public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
+
+ public Recording? Recording
+ {
+ get => _recording;
+ set
+ {
+ _recording = value;
+ _secondsPerPixel = -1;
+ _start = 0;
+ InvalidateVisual();
+ }
+ }
+
+ public double Time
+ {
+ get => _time;
+ set
+ {
+ _time = value;
+ // Kept in view while it plays.
+ if (_secondsPerPixel > 0 && _recording is not null)
+ {
+ double end = _start + PlotWidth * _secondsPerPixel;
+ if (value > end || value < _start)
+ _start = Math.Max(0, value - PlotWidth * _secondsPerPixel * 0.1);
+ }
+ InvalidateVisual();
+ }
+ }
+
+ /// All of the recording in view.
+ public void Fit()
+ {
+ _secondsPerPixel = -1;
+ _start = 0;
+ InvalidateVisual();
+ }
+
+ private double PlotWidth => Math.Max(10, Bounds.Width - Left - Right);
+ private double ChartBottom => Bounds.Height - AxisHeight - MarksHeight - Bands * (BandHeight + BandGap) - 6;
+ private double X(double t) => Left + (t - _start) / _secondsPerPixel;
+ private double TimeAt(double x) => _start + (x - Left) * _secondsPerPixel;
+
+ public override void Render(DrawingContext context)
+ {
+ var size = Bounds.Size;
+ var recording = _recording;
+ if (recording is null || recording.Duration <= 0)
+ return;
+ if (_secondsPerPixel <= 0)
+ _secondsPerPixel = Math.Max(recording.Duration, 60) / PlotWidth;
+
+ double end = _start + PlotWidth * _secondsPerPixel;
+ double chartBottom = ChartBottom;
+ Measure(recording);
+ double top = _speedTop;
+ double Y(double kmh) => chartBottom - Math.Clamp(kmh / top, 0, 1.05) * (chartBottom - Top);
+
+ using (context.PushClip(new Rect(Left, 0, PlotWidth, size.Height)))
+ {
+ DrawGrid(context, top, Y, size);
+ DrawCurves(context, recording, Y, end);
+ double bands = chartBottom + 6;
+ DrawBands(context, recording, bands, end);
+ DrawMarks(context, recording, bands + Bands * (BandHeight + BandGap), end);
+ DrawAxis(context, recording, size.Height - AxisHeight, end);
+ if (_hover is { } hover)
+ context.DrawLine(new Pen(Palette.DimText, 1, new DashStyle(new double[] { 2, 3 }, 0)),
+ new Point(X(hover), Top), new Point(X(hover), size.Height - AxisHeight));
+ double cursor = X(_time);
+ context.DrawLine(new Pen(Palette.Cursor, 1.5), new Point(cursor, Top - 6), new Point(cursor, size.Height - AxisHeight));
+ // The moment played, on the axis.
+ var clock = MapView.Text(recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), Palette.Cursor, 10, FontWeight.SemiBold);
+ var bubble = new Rect(Math.Max(Left, Math.Min(cursor - clock.Width / 2 - 5, 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);
+ if (MarkUnderPointer(recording, chartBottom) is { } mark)
+ DrawMarkLabel(context, mark, size);
+ }
+
+ /// The top of the speed scale and the highest step of the controller, once a reading.
+ private void Measure(Recording recording)
+ {
+ if (_measured == recording.Revision && ReferenceEquals(_measuredOf, recording))
+ return;
+ float max = 40;
+ int controller = 1;
+ foreach (var s in recording.Cab)
+ {
+ max = Math.Max(max, s.AbsSpeedKmh);
+ if (s.AiValid)
+ max = Math.Max(max, s.Ai.TargetKmh);
+ controller = Math.Max(controller, (int)s.MainController);
+ }
+ _speedTop = Math.Ceiling(max * 1.1 / 20) * 20;
+ _controllerTop = controller;
+ _measured = recording.Revision;
+ _measuredOf = recording;
+ }
+
+ private void DrawGrid(DrawingContext context, double top, Func y, Size size)
+ {
+ var pen = new Pen(Palette.Grid, 1);
+ for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
+ context.DrawLine(pen, new Point(Left, y(v)), new Point(size.Width - Right, y(v)));
+ }
+
+ private void DrawSpeedLabels(DrawingContext context, double top, Func y)
+ {
+ for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
+ {
+ var text = MapView.Text(v.ToString("0", CultureInfo.InvariantCulture), Palette.DimText, 10);
+ context.DrawText(text, new Point(Left - 6 - text.Width, y(v) - text.Height / 2));
+ }
+ }
+
+ ///
+ /// Each curve by columns of pixels: in a column the lowest and the highest speed,
+ /// so an hour draws as fast as a minute.
+ ///
+ private void DrawCurves(DrawingContext context, Recording recording, Func y, double end)
+ {
+ var cab = recording.Cab;
+ int first = Math.Max(0, recording.CabIndexAt(_start));
+ var speed = new Curve();
+ var target = new Curve();
+ var limits = new List();
+ int column = int.MinValue;
+ CabSample? before = null;
+ for (int i = first; i < cab.Count; i++)
+ {
+ var s = cab[i];
+ if (s.Time > end + _secondsPerPixel * 2)
+ break;
+ bool gap = before is { } b && (s.Time - b.Time > 1.0 || s.Vehicle != b.Vehicle);
+ if (gap)
+ {
+ speed.Break();
+ target.Break();
+ }
+ int c = (int)Math.Round(X(s.Time));
+ speed.Add(c, y(s.AbsSpeedKmh));
+ // Under the autopilot too: what it aimed at.
+ if (s.AiValid)
+ target.Add(c, y(s.Ai.TargetKmh));
+ else
+ target.Break();
+ if (c != column && s.AiValid && s.Ai.HasNextLimit)
+ limits.Add(new Point(c, y(s.Ai.NextLimitKmh)));
+ column = c;
+ before = s;
+ }
+ if (speed.Fill(y(0)) is { } fill)
+ context.DrawGeometry(Palette.SpeedFill, null, fill);
+ if (target.Geometry() is { } t)
+ context.DrawGeometry(null, new Pen(Palette.Target, 1.5), t);
+ var limitPen = new Pen(Palette.Limit, 2);
+ foreach (var p in limits)
+ context.DrawLine(limitPen, p, p + new Vector(1, 0));
+ if (speed.Geometry() is { } v)
+ context.DrawGeometry(null, new Pen(Palette.Speed, 1.5), v);
+ }
+
+ private void DrawBands(DrawingContext context, Recording recording, double top, double end)
+ {
+ var cab = recording.Cab;
+ int first = Math.Max(0, recording.CabIndexAt(_start));
+ var cameras = recording.Cameras;
+ int camera = -1;
+ var rows = new BandRun[Bands];
+ for (int row = 0; row < rows.Length; row++)
+ rows[row] = new BandRun(context, top + row * (BandHeight + BandGap));
+ for (int i = first; i < cab.Count; i++)
+ {
+ var s = cab[i];
+ if (s.Time > end)
+ break;
+ double next = i + 1 < cab.Count && cab[i + 1].Time - s.Time <= 1.0 ? cab[i + 1].Time : s.Time + 0.1;
+ double x0 = X(s.Time), x1 = Math.Max(X(next), x0 + 0.6);
+ rows[0].Add(x0, x1, Palette.Of(DrivingSummary.Judge(s)), BandHeight);
+ // The controller as high as its step stands against the highest one used.
+ rows[1].Add(x0, x1, s.MainController > 0 ? Palette.Controller : null,
+ Math.Max(1.5, BandHeight * s.MainController / _controllerTop));
+ rows[2].Add(x0, x1, s.BrakeCylinder > 0.2f ? BrakeShade(s.BrakeCylinder) : null, BandHeight);
+ if (s.Security != SecurityFlags.None || s.CabEnd != 0)
+ {
+ // The alerter and the cab signalling apart, red when not acknowledged in time.
+ rows[3].Add(x0, x1, s.Has(SecurityFlags.VigilanceBeeping) ? Palette.LampAlarm
+ : s.Has(SecurityFlags.Vigilance) ? Palette.Vigilance : null, BandHeight);
+ rows[4].Add(x0, x1, s.Has(SecurityFlags.CabSignalBeeping) ? Palette.LampAlarm
+ : s.Has(SecurityFlags.CabSignal) ? Palette.CabSignal : null, BandHeight);
+ if (s.Has(SecurityFlags.Braking))
+ rows[s.Has(SecurityFlags.CabSignal) && !s.Has(SecurityFlags.Vigilance) ? 4 : 3].Add(x0, x1, Palette.Bad, BandHeight);
+ }
+ else
+ rows[3].Add(x0, x1, (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
+ : (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.Vigilance : null, BandHeight);
+ // Where the user looked from: the samples go on in time, and so does the camera.
+ while (camera + 1 < cameras.Count && cameras[camera + 1].Time <= s.Time)
+ camera++;
+ rows[5].Add(x0, x1, camera < 0 ? null : CameraBrush(cameras[camera].Mode), BandHeight);
+ }
+ foreach (var row in rows)
+ row.Flush();
+ }
+
+ private static readonly IBrush CameraCab = Palette.Faded(Palette.UserTrain, 0.45);
+ private static readonly IBrush CameraOutside = Palette.Faded(Palette.HornPoint, 0.75);
+ private static readonly IBrush CameraFree = Palette.Faded(Palette.Station, 0.75);
+
+ private static IBrush CameraBrush(CameraMode mode) => mode switch
+ {
+ CameraMode.Cab => CameraCab,
+ CameraMode.External => CameraOutside,
+ _ => CameraFree
+ };
+
+ private static readonly IBrush[] BrakeShades = Shades(Palette.Brakes, 8);
+
+ private static IBrush[] Shades(IBrush brush, int count)
+ {
+ var colour = ((ISolidColorBrush)brush).Color;
+ var shades = new IBrush[count];
+ for (int i = 0; i < count; i++)
+ shades[i] = new SolidColorBrush(colour, 0.35 + 0.65 * i / (count - 1));
+ return shades;
+ }
+
+ /// The harder the brakes hold, the deeper the red.
+ private static IBrush BrakeShade(float cylinderBar) =>
+ BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
+
+ ///
+ /// The samples of a band drawn as few rectangles as the band changes: ten a second, each
+ /// narrower than a pixel, they would leave seams between them.
+ ///
+ private sealed class BandRun(DrawingContext context, double top)
+ {
+ private IBrush? _brush;
+ private double _height, _from, _to;
+
+ public void Add(double x0, double x1, IBrush? brush, double height)
+ {
+ if (brush is not null && ReferenceEquals(brush, _brush) && height == _height && x0 <= _to + 0.5)
+ {
+ _to = Math.Max(_to, x1);
+ return;
+ }
+ Flush();
+ _brush = brush;
+ _height = height;
+ _from = x0;
+ _to = x1;
+ }
+
+ public void Flush()
+ {
+ if (_brush is not null)
+ context.FillRectangle(_brush, new Rect(_from, top + BandHeight - _height, _to - _from, _height));
+ _brush = null;
+ }
+ }
+
+ private void DrawBandLabels(DrawingContext context, double top)
+ {
+ string[] names = { Strings["BandDriving"], Strings["BandController"], Strings["BandBrakes"], Strings["BandVigilance"],
+ Strings["BandCabSignal"], Strings["BandCamera"] };
+ for (int i = 0; i < names.Length; i++)
+ {
+ double y = top + i * (BandHeight + BandGap);
+ context.DrawRectangle(Palette.Faded(Palette.Grid, 0.5), null, new Rect(Left, y, Bounds.Width - Left - Right, BandHeight));
+ var text = MapView.Text(names[i], Palette.FaintText, 9);
+ context.DrawText(text, new Point(Left - 6 - text.Width, y + (BandHeight - text.Height) / 2));
+ }
+ var events = MapView.Text(Strings["BandEvents"], Palette.FaintText, 9);
+ double marks = top + Bands * (BandHeight + BandGap);
+ context.DrawText(events, new Point(Left - 6 - events.Width, marks + (MarksHeight - events.Height) / 2));
+ }
+
+ ///
+ /// The commands as short grey ticks, the autopilot switched in blue, the signals passed in
+ /// green, at danger tall and red; the stations as blue plates, the horn points as diamonds
+ /// green or red as the horn sounded, the couplings as yellow ticks.
+ ///
+ private void DrawMarks(DrawingContext context, Recording recording, double top, double end)
+ {
+ foreach (var station in recording.UserStations())
+ {
+ if (station.Time < _start || station.Time > end || station.Kind == StationKind.Ready)
+ continue;
+ double x = X(station.Time);
+ var brush = station.Kind == StationKind.Missed ? Palette.Bad : Palette.Station;
+ context.DrawLine(new Pen(Palette.Faded(brush, 0.35), 1), new Point(x, Top), new Point(x, top));
+ context.DrawRectangle(brush, null, new Rect(x - 3, Top - 13, 6, 8), 1.5, 1.5);
+ }
+ foreach (var horn in recording.UserHornChecks())
+ {
+ if (horn.Request.Time < _start || horn.Request.Time > end)
+ continue;
+ double x = X(horn.Request.Time);
+ var brush = horn.Sounded ? Palette.Good : Palette.Bad;
+ var geometry = new StreamGeometry();
+ using (var g = geometry.Open())
+ {
+ g.BeginFigure(new Point(x, Top - 14), true);
+ g.LineTo(new Point(x + 4, Top - 10));
+ g.LineTo(new Point(x, Top - 6));
+ g.LineTo(new Point(x - 4, Top - 10));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(brush, null, geometry);
+ }
+ var coupling = new Pen(Palette.Warn, 2);
+ foreach (var c in recording.UserCouplings())
+ if (c.Time >= _start && c.Time <= end)
+ context.DrawLine(coupling, new Point(X(c.Time), top + 1), new Point(X(c.Time), top + MarksHeight));
+ var command = new Pen(Palette.Command, 1);
+ foreach (var c in recording.UserCommands())
+ if (c.Time >= _start && c.Time <= end)
+ context.DrawLine(command, new Point(X(c.Time), top + MarksHeight - 5), new Point(X(c.Time), top + MarksHeight));
+ var autopilot = new Pen(Palette.Autopilot, 2);
+ foreach (var d in recording.DriverChanges)
+ if (d.Time >= _start && d.Time <= end)
+ context.DrawLine(autopilot, new Point(X(d.Time), top + 2), new Point(X(d.Time), top + MarksHeight));
+ foreach (var p in recording.UserPassings())
+ {
+ if (p.Time < _start || p.Time > end)
+ continue;
+ double x = X(p.Time);
+ if (p.AtDanger)
+ {
+ var geometry = new StreamGeometry();
+ using (var g = geometry.Open())
+ {
+ g.BeginFigure(new Point(x, Top - 4), true);
+ g.LineTo(new Point(x - 5, Top - 13));
+ g.LineTo(new Point(x + 5, Top - 13));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(Palette.Over, null, geometry);
+ context.DrawLine(new Pen(Palette.Over, 1.5), new Point(x, Top - 4), new Point(x, top + MarksHeight));
+ }
+ else
+ context.DrawLine(new Pen(Palette.Within, 1.5), new Point(x, top + 1), new Point(x, top + MarksHeight - 6));
+ }
+ }
+
+ private void DrawAxis(DrawingContext context, Recording recording, double y, double end)
+ {
+ double[] steps = { 10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200 };
+ double step = steps[^1];
+ foreach (double candidate in steps)
+ if (candidate / _secondsPerPixel >= 80)
+ {
+ step = candidate;
+ break;
+ }
+ var pen = new Pen(Palette.DimText, 1);
+ // On the clock of the simulation: the marks fall on whole minutes and hours of it.
+ double offset = recording.ClockAt(0).TotalSeconds;
+ double first = Math.Ceiling((_start + offset) / step) * step - offset;
+ for (double t = first; t <= end; t += step)
+ {
+ double x = X(t);
+ context.DrawLine(pen, new Point(x, y), new Point(x, y + 4));
+ var clock = recording.ClockAt(t);
+ string label = step < 60 ? clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture);
+ var text = MapView.Text(label, Palette.DimText, 10);
+ context.DrawText(text, new Point(x - text.Width / 2, y + 4));
+ }
+ }
+
+ private void DrawKey(DrawingContext context)
+ {
+ const double y = 10;
+ double x = Left + 4, limit = Bounds.Width - Right;
+ bool Room(string key) => x + 18 + MapView.Text(Strings[key], Palette.DimText, 10).Width <= limit;
+ void Name(string key)
+ {
+ var text = MapView.Text(Strings[key], Palette.DimText, 10);
+ context.DrawText(text, new Point(x + 18, y - text.Height / 2));
+ x += 18 + text.Width + 14;
+ }
+ foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") })
+ {
+ if (!Room(key))
+ return;
+ context.DrawLine(new Pen(brush, 2), new Point(x, y), new Point(x + 14, y));
+ Name(key);
+ }
+ // the marks, as they are drawn above the chart and in the row of the events
+ if (Room("KeyStation"))
+ {
+ context.DrawRectangle(Palette.Station, null, new Rect(x + 4, y - 4, 6, 8), 1.5, 1.5);
+ Name("KeyStation");
+ }
+ if (Room("KeyHorn"))
+ {
+ context.DrawGeometry(Palette.Good, null, MapView.Diamond(new Point(x + 7, y), 4));
+ Name("KeyHorn");
+ }
+ foreach (var (brush, key) in new[] { (Palette.Within, "KeySignal"), (Palette.Warn, "KeyCoupling"), (Palette.Autopilot, "KeyDriver"), (Palette.Command, "KeyCommand") })
+ {
+ if (!Room(key))
+ return;
+ context.DrawLine(new Pen(brush, 2), new Point(x + 7, y - 5), new Point(x + 7, y + 5));
+ Name(key);
+ }
+ }
+
+ /// What the mark under the pointer is: the nearest within a few pixels, in the row of the events or the plates above.
+ private string? MarkUnderPointer(Recording recording, double chartBottom)
+ {
+ if (_pointer is not { } pointer)
+ return null;
+ double marks = chartBottom + 6 + Bands * (BandHeight + BandGap);
+ bool inPlates = pointer.Y >= Top - 16 && pointer.Y <= Top - 2;
+ bool inMarks = pointer.Y >= marks - 2 && pointer.Y <= marks + MarksHeight + 2;
+ if (!inPlates && !inMarks || pointer.X < Left || pointer.X > Left + PlotWidth)
+ return null;
+ double end = _start + PlotWidth * _secondsPerPixel;
+ var culture = CultureInfo.CurrentCulture;
+ string? found = null;
+ double best = 5;
+ void Consider(double time, Func text)
+ {
+ if (time < _start || time > end)
+ return;
+ double d = Math.Abs(X(time) - pointer.X);
+ if (d < best)
+ {
+ best = d;
+ found = text();
+ }
+ }
+ if (inPlates)
+ {
+ foreach (var station in recording.UserStations())
+ if (station.Kind != StationKind.Ready)
+ Consider(station.Time, () => string.Format(culture, Strings[station.Kind switch
+ {
+ StationKind.Stopped => "TlStationStopped",
+ StationKind.Missed => "TlStationMissed",
+ _ => "TlStationPassed"
+ }], StationNames.Display(station.Station)));
+ foreach (var horn in recording.UserHornChecks())
+ Consider(horn.Request.Time, () => Strings[horn.Sounded ? "TlHornSounded" : "TlHornSilent"]);
+ }
+ foreach (var p in recording.UserPassings())
+ {
+ if (!inMarks && !p.AtDanger)
+ continue;
+ Consider(p.Time, () =>
+ {
+ string name = recording.Signal(p.Signal)?.Name ?? p.Event;
+ return p.AtDanger
+ ? string.Format(culture, Strings["TlSignalDanger"], name)
+ : string.Format(culture, Strings["TlSignal"], name, MapView.AspectText(Strings, p.Speed1, p.Speed2));
+ });
+ }
+ if (inMarks)
+ {
+ foreach (var c in recording.UserCouplings())
+ Consider(c.Time, () => Strings[c.Coupled ? "TlCoupled" : c.Uncoupled ? "TlUncoupled" : "TlCouplingChanged"]);
+ foreach (var d in recording.DriverChanges)
+ Consider(d.Time, () => Strings[d.Driving == Driver.Ai ? "TlAutopilotOn" : "TlAutopilotOff"]);
+ foreach (var c in recording.UserCommands())
+ Consider(c.Time, () => string.Format(culture, Strings["TlCommand"], c.Name));
+ }
+ return found;
+ }
+
+ private void DrawMarkLabel(DrawingContext context, string text, Size size)
+ {
+ if (_pointer is not { } pointer)
+ return;
+ var label = MapView.Text(text, Palette.Text, 11);
+ double x = Math.Max(Left, Math.Min(pointer.X + 10, size.Width - Right - label.Width - 12));
+ double y = Math.Max(2, pointer.Y - label.Height - 12);
+ var box = new Rect(x, y, label.Width + 12, label.Height + 6);
+ context.DrawRectangle(Palette.Card, new Pen(Palette.CardBorder, 1), box, 3, 3);
+ context.DrawText(label, new Point(x + 6, y + 3));
+ }
+
+ 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;
+ _pointer = position;
+ 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;
+ _pointer = null;
+ Hovered?.Invoke(null);
+ InvalidateVisual();
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ if (_recording is null || _secondsPerPixel <= 0)
+ return;
+ double x = e.GetPosition(this).X;
+ double at = TimeAt(x);
+ double whole = Math.Max(_recording.Duration, 60) / PlotWidth;
+ _secondsPerPixel = Math.Clamp(_secondsPerPixel / Math.Pow(1.3, e.Delta.Y), 0.02, whole);
+ _start = Math.Max(0, at - (x - Left) * _secondsPerPixel);
+ InvalidateVisual();
+ e.Handled = true;
+ }
+
+ /// A line through columns of pixels: in each the first, the lowest, the highest and the last value.
+ private sealed class Curve
+ {
+ private readonly List> _lines = new();
+ private List? _line;
+ private int _column = int.MinValue;
+ private double _first, _low, _high, _last;
+
+ public void Add(int column, double y)
+ {
+ if (column != _column)
+ {
+ Flush();
+ _column = column;
+ _first = _low = _high = y;
+ }
+ _low = Math.Min(_low, y);
+ _high = Math.Max(_high, y);
+ _last = y;
+ }
+
+ public void Break()
+ {
+ Flush();
+ _line = null;
+ _column = int.MinValue;
+ }
+
+ private void Flush()
+ {
+ if (_column == int.MinValue)
+ return;
+ if (_line is null)
+ _lines.Add(_line = new List());
+ var (a, b) = _first <= _last ? (_low, _high) : (_high, _low);
+ _line.Add(new Point(_column, _first));
+ if (Math.Abs(_high - _low) > 0.5)
+ {
+ _line.Add(new Point(_column, a));
+ _line.Add(new Point(_column, b));
+ }
+ _line.Add(new Point(_column, _last));
+ }
+
+ /// The area under the line down to the bottom given, for a soft fill.
+ public StreamGeometry? Fill(double bottom)
+ {
+ Flush();
+ _column = int.MinValue;
+ var geometry = new StreamGeometry();
+ bool any = false;
+ using (var g = geometry.Open())
+ {
+ foreach (var line in _lines)
+ {
+ if (line.Count < 2)
+ continue;
+ g.BeginFigure(new Point(line[0].X, bottom), true);
+ foreach (var point in line)
+ g.LineTo(point);
+ g.LineTo(new Point(line[^1].X, bottom));
+ g.EndFigure(true);
+ any = true;
+ }
+ }
+ return any ? geometry : null;
+ }
+
+ public StreamGeometry? Geometry()
+ {
+ Flush();
+ _column = int.MinValue;
+ var geometry = new StreamGeometry();
+ bool any = false;
+ using (var g = geometry.Open())
+ {
+ foreach (var line in _lines)
+ {
+ if (line.Count < 2)
+ continue;
+ g.BeginFigure(line[0], false);
+ for (int i = 1; i < line.Count; i++)
+ g.LineTo(line[i]);
+ g.EndFigure(false);
+ any = true;
+ }
+ }
+ return any ? geometry : null;
+ }
+ }
+}
diff --git a/Eu07.BlackBox.Views/TimetablePanel.cs b/Eu07.BlackBox.Views/TimetablePanel.cs
new file mode 100644
index 0000000..625c2f4
--- /dev/null
+++ b/Eu07.BlackBox.Views/TimetablePanel.cs
@@ -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;
+
+///
+/// The timetable of the user's train, a row a station: what the timetable said and what
+/// happened, by the end of the recording read so far. A stop shows the time of arrival and
+/// of departure with the delay, how well the train was stopped against the stop point and
+/// whether it stood along the platform; a station passed through the time of passing. The
+/// station of the moment played is marked; a click goes to the moment the train was there.
+///
+public sealed class TimetablePanel : Control
+{
+ private const double Pad = 12;
+ private const double HeaderHeight = 50;
+ private const double RowHeight = 54;
+
+ private Recording? _recording;
+ private double _time;
+ private int _builtRevision = -1;
+ private IReadOnlyList _rows = Array.Empty();
+ private Timetable? _timetable;
+ private int _hover = -1;
+
+ public event Action? Seek;
+
+ public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
+
+ public TimetablePanel()
+ {
+ ClipToBounds = true;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ }
+
+ public Recording? Recording
+ {
+ get => _recording;
+ set
+ {
+ _recording = value;
+ _builtRevision = -1;
+ Rebuild();
+ }
+ }
+
+ public double Time
+ {
+ get => _time;
+ set
+ {
+ _time = value;
+ Rebuild();
+ InvalidateVisual();
+ }
+ }
+
+ private void Rebuild()
+ {
+ if (_recording is not { } recording)
+ {
+ _rows = Array.Empty();
+ _timetable = null;
+ InvalidateMeasure();
+ return;
+ }
+ var timetable = recording.TimetableAt(_time);
+ if (recording.Revision == _builtRevision && ReferenceEquals(timetable, _timetable))
+ return;
+ _builtRevision = recording.Revision;
+ _timetable = timetable;
+ _rows = StationProgress.Of(recording, _time);
+ InvalidateMeasure();
+ InvalidateVisual();
+ }
+
+ protected override Size MeasureOverride(Size availableSize) =>
+ new(0, _timetable is null ? 80 : HeaderHeight + _rows.Count * RowHeight + Pad);
+
+ /// The row of the station the train was at, or made for, at the moment played.
+ private int Current()
+ {
+ int current = -1;
+ for (int i = 0; i < _rows.Count; i++)
+ {
+ var row = _rows[i];
+ double? at = row.ArrivedAt ?? row.DepartedAt;
+ if (at is { } t && t <= _time + 0.05)
+ current = i;
+ }
+ // Still standing there, or on the way to the next one.
+ if (current >= 0 && _rows[current].DepartedAt is { } left && left <= _time && current + 1 < _rows.Count)
+ return current + 1;
+ return current < 0 ? (_rows.Count > 0 ? 0 : -1) : current;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var size = Bounds.Size;
+ var culture = CultureInfo.CurrentCulture;
+ if (_timetable is not { } timetable)
+ {
+ context.DrawText(MapView.Text(Strings["TimetableNone"], Palette.DimText, 12), new Point(Pad, Pad));
+ return;
+ }
+
+ // The train: its number and category, from where to where.
+ var train = MapView.Text($"{timetable.Category} {timetable.Train}".Trim(), Palette.Text, 15, FontWeight.SemiBold);
+ context.DrawText(train, new Point(Pad, 6));
+ string relation = string.Format(culture, Strings["Relation"], timetable.From, timetable.To);
+ if (timetable.Vmax > 0)
+ relation += $" · {timetable.Vmax:0} km/h";
+ context.DrawText(MapView.Text(relation, Palette.DimText, 11), new Point(Pad, 6 + train.Height + 1));
+
+ int current = Current();
+ double rail = Pad + 6;
+ for (int i = 0; i < _rows.Count; i++)
+ {
+ double top = HeaderHeight + i * RowHeight;
+ var row = _rows[i];
+ if (i == current)
+ {
+ context.DrawRectangle(Palette.CardHover, null, new Rect(0, top, size.Width, RowHeight - 1));
+ context.DrawRectangle(Palette.Accent, null, new Rect(0, top, 2, RowHeight - 1));
+ }
+ else if (i == _hover)
+ context.DrawRectangle(Palette.Faded(Palette.CardHover, 0.6), null, new Rect(0, top, size.Width, RowHeight - 1));
+
+ // The line of the route through the stations, a dot each: hollow ahead, filled when done.
+ var outcome = row.Outcome;
+ var colour = outcome switch
+ {
+ StationOutcome.Missed => Palette.Bad,
+ StationOutcome.Ahead => Palette.FaintText,
+ _ => DelayBrush(row.ArrivalDelay ?? row.DepartureDelay)
+ };
+ double dotY = top + 15;
+ if (i + 1 < _rows.Count)
+ context.DrawLine(new Pen(Palette.Grid, 2), new Point(rail, dotY), new Point(rail, dotY + RowHeight));
+ bool stop = row.Row.IsStop;
+ double radius = stop ? 5.5 : 3.5;
+ context.DrawEllipse(outcome == StationOutcome.Ahead ? Palette.Card : colour, new Pen(colour, 2), new Point(rail, dotY), radius, radius);
+
+ // The station and what the timetable said.
+ double x = rail + 16;
+ var name = MapView.Text(StationNames.Display(row.Row.Station), outcome == StationOutcome.Ahead ? Palette.DimText : Palette.Text, 13,
+ stop ? FontWeight.SemiBold : FontWeight.Normal);
+ context.DrawText(name, new Point(x, dotY - name.Height / 2));
+ string planned = stop
+ ? $"{Minutes(row.Row.Arrival)} → {Minutes(row.Row.Departure)}"
+ : Minutes(row.Row.Departure);
+ var plan = MapView.Text(planned, Palette.DimText, 12);
+ context.DrawText(plan, new Point(size.Width - Pad - plan.Width, dotY - plan.Height / 2));
+ if (row.Row.Km > 0 || i > 0)
+ {
+ var km = MapView.Text($"km {row.Row.Km:0.0}", Palette.FaintText, 10);
+ double kmX = size.Width - Pad - plan.Width - 12 - km.Width;
+ if (kmX > x + name.Width + 8)
+ context.DrawText(km, new Point(kmX, dotY - km.Height / 2));
+ }
+
+ // What happened there, under it: each part in its colour, on to a second line when long.
+ double partX = x, partY = top + 25;
+ bool first = true;
+ foreach (var (text, brush) in Chips(row, culture))
+ {
+ var part = MapView.Text(first ? text : "· " + text, brush, 11);
+ if (!first && partX + part.Width > size.Width - Pad)
+ {
+ partX = x;
+ partY += 14;
+ part = MapView.Text(text, brush, 11);
+ }
+ context.DrawText(part, new Point(partX, partY));
+ partX += part.Width + 6;
+ first = false;
+ }
+ }
+ }
+
+ /// What happened at a station, each part with its colour.
+ private IEnumerable<(string Text, IBrush Brush)> Chips(StationProgress row, CultureInfo culture)
+ {
+ switch (row.Outcome)
+ {
+ case StationOutcome.Ahead:
+ if (row.Row.Maintenance)
+ yield return (Strings["TtTechnical"], Palette.FaintText);
+ yield break;
+ case StationOutcome.Passed:
+ yield return (string.Format(culture, Strings["TtPassed"], Clock(row.DepartureClock)), Palette.DimText);
+ if (row.DepartureDelay is { } passDelay)
+ yield return (string.Format(culture, Strings["Minutes"], passDelay), DelayBrush(passDelay));
+ yield break;
+ }
+ if (row.ArrivedAt is null)
+ {
+ yield return (Strings["TtMissed"], Palette.Bad);
+ yield break;
+ }
+ yield return (string.Format(culture, Strings["TtArrived"], Clock(row.ArrivalClock)), Palette.DimText);
+ if (row.ArrivalDelay is { } arrival)
+ yield return (string.Format(culture, Strings["Minutes"], arrival), DelayBrush(arrival));
+ if (row.Outcome == StationOutcome.Missed)
+ yield return (Strings["TtLeftEarly"], Palette.Bad);
+ else if (row.DepartedAt is not null)
+ {
+ yield return (string.Format(culture, Strings["TtDeparted"], Clock(row.DepartureClock)), Palette.DimText);
+ if (row.DepartureDelay is { } departure)
+ yield return (string.Format(culture, Strings["Minutes"], departure), DelayBrush(departure));
+ }
+ else
+ yield return (Strings["TtStanding"], Palette.Station);
+ if (row.StopError is { } error)
+ yield return Math.Abs(error) < 1
+ ? (Strings["TtStopExact"], Palette.Good)
+ : (string.Format(culture, Strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error)),
+ Math.Abs(error) <= 5 ? Palette.Good : Math.Abs(error) <= 15 ? Palette.Warn : Palette.Bad);
+ if (row.OnPlatform is { } along)
+ yield return along
+ ? (Strings["TtOnPlatform"], Palette.Good)
+ : (string.Format(culture, Strings["TtOffPlatform"], row.OffPlatform ?? 0), Palette.Bad);
+ }
+
+ private static IBrush DelayBrush(double? minutes) => Palette.Delay(minutes);
+
+ private static string Minutes(float minutes) =>
+ minutes < 0 ? "–" : TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm", CultureInfo.InvariantCulture);
+
+ private static string Clock(double? minutes) =>
+ minutes is { } m ? TimeSpan.FromMinutes(m % 1440).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : "–";
+
+ private int RowAt(Point p)
+ {
+ int i = (int)Math.Floor((p.Y - HeaderHeight) / RowHeight);
+ return p.Y >= HeaderHeight && i >= 0 && i < _rows.Count ? i : -1;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ int hover = RowAt(e.GetPosition(this));
+ if (hover != _hover)
+ {
+ _hover = hover;
+ InvalidateVisual();
+ }
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ _hover = -1;
+ InvalidateVisual();
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ int i = RowAt(e.GetPosition(this));
+ if (i >= 0 && (_rows[i].ArrivedAt ?? _rows[i].DepartedAt) is { } t)
+ Seek?.Invoke(Math.Max(0, t - 5));
+ }
+}
diff --git a/Eu07.BlackBox.Views/UiKit.cs b/Eu07.BlackBox.Views/UiKit.cs
new file mode 100644
index 0000000..0fbbd72
--- /dev/null
+++ b/Eu07.BlackBox.Views/UiKit.cs
@@ -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;
+
+///
+/// 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.
+///
+internal static class UiKit
+{
+ /// The gutter between the panels.
+ public const double Gap = 6;
+ public static readonly CornerRadius Small = new(2);
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.
+ 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);
+ }
+
+ /// A panel in the frame of the starter's group boxes, without a title.
+ public static Border Frame(Control content) => new()
+ {
+ Background = Palette.Panel,
+ BorderBrush = Palette.CardBorder,
+ BorderThickness = new Thickness(1),
+ CornerRadius = Small,
+ ClipToBounds = true,
+ Child = content
+ };
+
+ /// A flat panel the views sit on.
+ public static Border Card(Control content, Thickness? padding = null) => new()
+ {
+ Background = Palette.Card,
+ Padding = padding ?? new Thickness(0),
+ ClipToBounds = true,
+ Child = content
+ };
+
+ /// A button of the toolbar: text on the bar, a lighter ground under the pointer.
+ 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;
+ }
+
+ /// The button of playing.
+ 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;
+ }
+
+ /// The glyph of play or pause.
+ 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
+ };
+ }
+
+ /// A toggle of the toolbar: on, its text takes the accent.
+ 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;
+ }
+ }
+
+ /// A line of the table of figures: what it is on the left, its value on the right, a note under it.
+ 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);
+ }
+ }
+
+ /// Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.
+ public sealed class Segmented : Border
+ {
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
+ private int _selected = -1;
+
+ public event Action? 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);
+ }
+ }
+ }
+}
diff --git a/Eu07.BlackBox/BlackBoxReader.cs b/Eu07.BlackBox/BlackBoxReader.cs
new file mode 100644
index 0000000..55b4da9
--- /dev/null
+++ b/Eu07.BlackBox/BlackBoxReader.cs
@@ -0,0 +1,331 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Text;
+
+namespace Eu07.BlackBox;
+
+///
+/// Reads a black box into a , as much of it as is there. A file
+/// the simulator still writes is read again from where the last read stopped: a record
+/// cut short waits for the next read, so a running session can be followed every few
+/// seconds without reading it from the start.
+///
+public sealed class BlackBoxReader
+{
+ public static ReadOnlySpan Magic => "EU07BBX\0"u8;
+ public const int FormatVersion = 1;
+ public const int HeaderSize = 12;
+ public const int RecordHeaderSize = 5;
+
+ private long _offset;
+
+ public Recording Recording { get; } = new();
+
+ /// Bytes taken so far: the header and the whole records.
+ public long Offset => _offset;
+
+ /// Opens the file shared, as the simulator may be writing it, and reads what is new.
+ public int ReadMore(string path)
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ return ReadMore(stream);
+ }
+
+ /// Reads the whole records added since the last read. Returns how many were read.
+ /// Not a black box, or of a version this reader doesn't know.
+ public int ReadMore(Stream stream)
+ {
+ if (stream.CanSeek)
+ {
+ if (stream.Length < _offset)
+ throw new InvalidDataException("The black box got shorter than what was read of it: it was written anew.");
+ stream.Seek(_offset, SeekOrigin.Begin);
+ }
+
+ using var added = new MemoryStream();
+ stream.CopyTo(added);
+ var bytes = added.GetBuffer().AsSpan(0, (int)added.Length);
+
+ int at = 0;
+ if (_offset == 0)
+ {
+ if (bytes.Length < HeaderSize)
+ return 0;
+ ReadHeader(bytes[..HeaderSize]);
+ at = HeaderSize;
+ }
+
+ int read = 0;
+ while (bytes.Length - at >= RecordHeaderSize)
+ {
+ var type = (RecordType)bytes[at];
+ uint length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(at + 1, 4));
+ if (length > int.MaxValue || bytes.Length - at - RecordHeaderSize < length)
+ break;
+
+ var payload = bytes.Slice(at + RecordHeaderSize, (int)length);
+ try
+ {
+ Take(type, new PayloadReader(payload));
+ }
+ catch (EndOfStreamException)
+ {
+ // Shorter than its type says: passed over, as one of an unknown type is.
+ Recording.Malformed++;
+ }
+ at += RecordHeaderSize + (int)length;
+ ++read;
+ }
+
+ _offset += at;
+ if (read > 0)
+ Recording.Changed();
+ return read;
+ }
+
+ /// Reads a whole file at once.
+ public static Recording Read(string path)
+ {
+ var reader = new BlackBoxReader();
+ reader.ReadMore(path);
+ return reader.Recording;
+ }
+
+ public static Recording Read(Stream stream)
+ {
+ var reader = new BlackBoxReader();
+ reader.ReadMore(stream);
+ return reader.Recording;
+ }
+
+ /// Whether the bytes start as a black box does.
+ public static bool LooksLikeBlackBox(ReadOnlySpan start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
+
+ private void ReadHeader(ReadOnlySpan header)
+ {
+ if (!LooksLikeBlackBox(header))
+ throw new InvalidDataException("Not a black box of the simulator.");
+ int version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(8, 2));
+ if (version != FormatVersion)
+ throw new InvalidDataException($"A black box of version {version}, this reader knows version {FormatVersion}.");
+ Recording.Version = version;
+ }
+
+ private void Take(RecordType type, PayloadReader r)
+ {
+ var rec = Recording;
+ switch (type)
+ {
+ case RecordType.Info:
+ rec.Info = new RecordingInfo(r.Text(), r.Text(), r.U16(), r.U16(), r.F64(), r.Text(), r.Text(),
+ r.F32(), r.F32(), r.F32());
+ break;
+ case RecordType.Track:
+ {
+ int index = (int)r.U32();
+ string name = r.Text();
+ var kind = (TrackKind)r.U8();
+ int branch = r.U8();
+ int category = r.U8();
+ uint count = r.U32();
+ if (count > r.Remaining / 12)
+ throw new EndOfStreamException();
+ var points = new Vector3[count];
+ for (int i = 0; i < points.Length; i++)
+ points[i] = r.Vec3();
+ rec.AddTrack(new MapTrack(index, name, kind, branch, category, points));
+ break;
+ }
+ case RecordType.Signal:
+ rec.AddSignal(new MapSignal((int)r.U32(), r.Text(), r.DVec3()));
+ break;
+ case RecordType.Vehicle:
+ rec.AddVehicle(new VehicleInfo((int)r.U32(), r.Text(), r.Text(), r.Text(), r.F32(), r.F32(), r.U8()));
+ break;
+ case RecordType.VehicleRemoved:
+ rec.RemoveVehicle(r.F64(), (int)r.U32());
+ break;
+ case RecordType.Cab:
+ {
+ var sample = new CabSample(r.F64(), (int)r.U32(), r.DVec3(), r.F32(), r.F32(), r.F32(), (Driver)r.U8(),
+ (CabFlags)r.U8(), r.I8(), r.I8(), r.I8(), r.F32(), r.F32(), r.F32(), r.F32(),
+ r.F32(),
+ new AiReference(r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()),
+ (CabDevices)r.U32(), r.F32(), r.F32(), r.F32(), (Lights)r.U16(), (Lights)r.U16());
+ // The cab, the train protection and the lateral acceleration came later: a recording
+ // made before them ends here.
+ if (r.Remaining >= 6)
+ sample = sample with { CabEnd = r.I8(), Security = (SecurityFlags)r.U8(), LateralAcceleration = r.F32() };
+ // What the driver went by and the lights asked for came later still.
+ if (r.Remaining >= 5)
+ sample = sample with { Movement = (Movement)r.U8(), LightHintHead = (short)r.U16(), LightHintTail = (short)r.U16() };
+ // The lamps the vehicles at the ends have, which a pattern is checked against, later again.
+ if (r.Remaining >= 4)
+ sample = sample with { EquippedHead = (Lights)r.U16(), EquippedTail = (Lights)r.U16() };
+ // What drives it, the doors' permits and the rest, later again; the arguments are read in their order.
+ if (r.Remaining >= 54)
+ sample = sample with
+ {
+ Extra = new CabExtra((EngineKind)r.U8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(),
+ r.F32(), r.F32(), r.F32(), r.I8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32())
+ };
+ rec.AddCab(sample);
+ break;
+ }
+ case RecordType.StopPoint:
+ {
+ var point = new MapStopPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32());
+ // The heading came later: a recording made before it ends here.
+ if (r.Remaining >= 4)
+ point = point with { Heading = r.F32() };
+ rec.AddStopPoint(point);
+ break;
+ }
+ case RecordType.HornPoint:
+ rec.AddHornPoint(new MapHornPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32()));
+ break;
+ case RecordType.Timetable:
+ {
+ double time = r.F64();
+ int vehicle = (int)r.U32();
+ string train = r.Text(), category = r.Text(), from = r.Text(), to = r.Text();
+ float vmax = r.F32();
+ uint count = r.U32();
+ // A row holds at least its empty name, four numbers and a flag.
+ if (count > r.Remaining / 18)
+ throw new EndOfStreamException();
+ var rows = new TimetableRow[count];
+ for (int i = 0; i < rows.Length; i++)
+ rows[i] = new TimetableRow(r.Text(), r.F32(), r.F32(), r.F32(), r.F32(), r.U8() != 0);
+ rec.AddTimetable(new Timetable(time, vehicle, train, category, from, to, vmax, rows));
+ break;
+ }
+ case RecordType.Station:
+ rec.AddStation(new StationEvent(r.F64(), (int)r.U32(), (StationKind)r.U8(), r.U16(), r.Text(), r.DVec3(),
+ r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()));
+ break;
+ case RecordType.Camera:
+ rec.AddCamera(new CameraState(r.F64(), (CameraMode)r.U8(), r.U8(), (int)r.U32(), r.DVec3(), r.F32(), r.F32()));
+ break;
+ case RecordType.Coupling:
+ rec.AddCoupling(new CouplingChange(r.F64(), (int)r.U32(), r.U8(), (int)r.U32(), r.U8(), (CouplingFlags)r.U8(),
+ (CouplingFlags)r.U8(), (int)r.U32(), r.DVec3()));
+ break;
+ case RecordType.HornRequest:
+ rec.AddHornRequest(new HornRequest(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.DVec3()));
+ break;
+ case RecordType.Vehicles:
+ {
+ double time = r.F64();
+ bool keyframe = r.U8() != 0;
+ uint count = r.U32();
+ if (count > r.Remaining / 25)
+ throw new EndOfStreamException();
+ var states = new VehicleState[count];
+ for (int i = 0; i < states.Length; i++)
+ states[i] = new VehicleState((int)r.U32(), r.Vec3(), r.F32(), r.F32(), (Driver)r.U8());
+ rec.AddLook(new VehicleLook(time, keyframe, states));
+ break;
+ }
+ case RecordType.SwitchState:
+ rec.AddSwitch(new SwitchChange(r.F64(), (int)r.U32(), r.U8()));
+ break;
+ case RecordType.SignalAspect:
+ rec.AddAspect(new AspectChange(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.Text()));
+ break;
+ case RecordType.Command:
+ {
+ double time = r.F64();
+ int vehicle = (int)r.U32();
+ bool pressed = r.U8() != 0;
+ double param1 = r.F64(), param2 = r.F64();
+ rec.AddCommand(new CommandEntry(time, vehicle, pressed, param1, param2, r.Text()));
+ break;
+ }
+ case RecordType.DriverChange:
+ rec.AddDriverChange(new DriverChange(r.F64(), (int)r.U32(), (Driver)r.U8()));
+ break;
+ case RecordType.SignalPassed:
+ {
+ double time = r.F64();
+ int vehicle = (int)r.U32();
+ int signal = unchecked((int)r.U32());
+ rec.AddPassing(new SignalPassing(time, vehicle, signal, r.Text(), r.DVec3(), r.F32(), r.F32(), r.F32(),
+ r.F32(), (PassingFlags)r.U8()));
+ break;
+ }
+ case RecordType.UserVehicle:
+ {
+ var change = new UserVehicleChange(r.F64(), (int)r.U32(), r.U8() != 0);
+ if (r.Remaining >= 1)
+ change = change with { CabEnd = r.I8() };
+ rec.AddUserVehicle(change);
+ break;
+ }
+ case RecordType.Clock:
+ rec.AddClock(new ClockEntry(r.F64(), r.U16(), r.U16(), r.F64()));
+ break;
+ case RecordType.End:
+ rec.EndTime = r.F64();
+ break;
+ default:
+ // A type of a later version: its length lets it be passed over.
+ rec.Unknown++;
+ break;
+ }
+ }
+
+ /// The fields of a record in the order they come, little-endian. A field past the end throws.
+ private ref struct PayloadReader
+ {
+ private readonly ReadOnlySpan _bytes;
+ private int _at;
+
+ public PayloadReader(ReadOnlySpan bytes)
+ {
+ _bytes = bytes;
+ _at = 0;
+ }
+
+ public int Remaining => _bytes.Length - _at;
+
+ private ReadOnlySpan Take(int count)
+ {
+ if (Remaining < count)
+ throw new EndOfStreamException();
+ var span = _bytes.Slice(_at, count);
+ _at += count;
+ return span;
+ }
+
+ public byte U8() => Take(1)[0];
+ public sbyte I8() => unchecked((sbyte)Take(1)[0]);
+ public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
+ public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
+ public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4));
+ public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8));
+ public Vector3 Vec3() => new(F32(), F32(), F32());
+ public Position DVec3() => new(F64(), F64(), F64());
+
+ /// UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.
+ public string Text()
+ {
+ int length = 0;
+ for (int shift = 0; ; shift += 7)
+ {
+ if (shift > 28)
+ throw new EndOfStreamException();
+ byte b = U8();
+ length |= (b & 0x7f) << shift;
+ if (b < 0x80)
+ break;
+ }
+ if (length < 0)
+ throw new EndOfStreamException();
+ return length == 0 ? "" : Encoding.UTF8.GetString(Take(length));
+ }
+ }
+}
diff --git a/Eu07.BlackBox/DrivingSummary.cs b/Eu07.BlackBox/DrivingSummary.cs
new file mode 100644
index 0000000..71e9095
--- /dev/null
+++ b/Eu07.BlackBox/DrivingSummary.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eu07.BlackBox;
+
+/// How a sample of the user's train stands against what the AI would have done.
+public enum SpeedJudgement
+{
+ /// Nothing to set it against: the consist not started, or the user out of the cab.
+ NoData,
+ /// The AI drove.
+ Autopilot,
+ /// At most the speed the AI would hold.
+ Within,
+ /// Faster, by no more than the tolerance.
+ Slightly,
+ /// Faster by more than the tolerance.
+ Over
+}
+
+/// What the user's driving came to, set against the AI's.
+public sealed record DrivingSummary(
+ double Duration,
+ double DrivenSeconds,
+ double AutopilotSeconds,
+ double DistanceKm,
+ float MaxSpeedKmh,
+ double OverAiSeconds,
+ double WellOverAiSeconds,
+ float MaxOverAiKmh,
+ double MaxOverAiAt,
+ int SignalsPassed,
+ IReadOnlyList PassedAtDanger,
+ int Commands,
+ int AutopilotSwitches,
+ float MaxAcceleration,
+ float MaxDeceleration,
+ float MaxLateralAcceleration,
+ int Stops,
+ int StopsMissed,
+ double? MaxDelayMinutes,
+ int HornPoints,
+ int HornsMissed,
+ int SecurityBrakings)
+{
+ /// The largest delay the user's train left a station or passed it with [min], null without any.
+ public double? MaxDepartureDelayMinutes { get; init; }
+
+ /// How long the train moved with its light signals known to be right or wrong [s].
+ public double LightsJudgedSeconds { get; init; }
+
+ /// How long of that its head or its tail showed other signals than it was to [s].
+ public double WrongLightsSeconds { get; init; }
+
+ /// Each stretch the train moved with the wrong signals, as they were.
+ public IReadOnlyList LightsFaults { get; init; } = Array.Empty();
+
+ /// The stops where the train did not fit along the platform, and how far its tail stood off it [m].
+ public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
+
+ /// [km/h] over the speed of the AI before it counts as well over.
+ public const float DefaultTolerance = 5f;
+
+ ///
+ /// The speeds are whole km/h for the AI and the needle of the user's train shakes:
+ /// half a km/h over is still within.
+ ///
+ private const float Rounding = 0.5f;
+
+ public static SpeedJudgement Judge(CabSample sample, float tolerance = DefaultTolerance)
+ {
+ if (sample.Driving == Driver.Ai)
+ return SpeedJudgement.Autopilot;
+ if (!sample.AiValid || sample.Driving != Driver.Human)
+ return SpeedJudgement.NoData;
+ float over = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
+ return over <= Rounding ? SpeedJudgement.Within : over <= tolerance ? SpeedJudgement.Slightly : SpeedJudgement.Over;
+ }
+
+ internal static DrivingSummary Of(Recording recording, float tolerance)
+ {
+ var cab = recording.Cab;
+ double driven = 0, autopilot = 0, distance = 0, over = 0, wellOver = 0, maxOverAt = 0;
+ float maxSpeed = 0, maxOver = 0, maxAcceleration = 0, maxDeceleration = 0, maxLateral = 0;
+ // The accelerations over half a second: a sample alone jumps with each jolt of the couplers.
+ var window = new Queue<(float Along, float Across)>();
+ float sumAlong = 0, sumAcross = 0;
+ double lightsJudged = 0, lightsWrong = 0;
+ var faults = new List();
+ // A step longer than this is a gap in the samples, not driving.
+ double gap = (recording.Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
+ for (int i = 1; i < cab.Count; i++)
+ {
+ var before = cab[i - 1];
+ var sample = cab[i];
+ double step = sample.Time - before.Time;
+ if (step <= 0 || step > gap || sample.Vehicle != before.Vehicle)
+ {
+ window.Clear();
+ sumAlong = sumAcross = 0;
+ continue;
+ }
+
+ distance += (before.AbsSpeedKmh + sample.AbsSpeedKmh) / 2 / 3.6 * step / 1000;
+ window.Enqueue((sample.Acceleration, sample.LateralAcceleration));
+ sumAlong += sample.Acceleration;
+ sumAcross += sample.LateralAcceleration;
+ if (window.Count > 5)
+ {
+ var (along, across) = window.Dequeue();
+ sumAlong -= along;
+ sumAcross -= across;
+ }
+ if (window.Count == 5 && sample.AbsSpeedKmh > 1)
+ {
+ maxAcceleration = Math.Max(maxAcceleration, sumAlong / 5);
+ maxDeceleration = Math.Max(maxDeceleration, -sumAlong / 5);
+ maxLateral = Math.Max(maxLateral, Math.Abs(sumAcross / 5));
+ }
+ maxSpeed = Math.Max(maxSpeed, sample.AbsSpeedKmh);
+ if (sample.Driving == Driver.Ai)
+ autopilot += step;
+ else if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > Rounding)
+ driven += step;
+
+ // the light signals at both ends, while the user drives the train and it moves: the autopilot's are its own
+ if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > 1 &&
+ HeadCodes.HeadRight(sample) is { } headRight && HeadCodes.TailRight(sample) is { } tailRight)
+ {
+ lightsJudged += step;
+ if (!headRight || !tailRight)
+ {
+ lightsWrong += step;
+ var fault = new LightsFault(before.Time, sample.Time, HeadCodes.Of(sample.LightsHead), HeadCodes.RequiredHead(sample)!.Value,
+ !headRight, HeadCodes.Of(sample.LightsTail), HeadCodes.RequiredTail(sample)!.Value, !tailRight);
+ if (faults.Count > 0 && faults[^1].SameAs(fault) && fault.Start - faults[^1].End < 2)
+ faults[^1] = faults[^1] with { End = sample.Time };
+ else
+ faults.Add(fault);
+ }
+ }
+
+ var judgement = Judge(sample, tolerance);
+ if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
+ over += step;
+ if (judgement == SpeedJudgement.Over)
+ wellOver += step;
+ if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
+ {
+ float by = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
+ if (by > maxOver)
+ {
+ maxOver = by;
+ maxOverAt = sample.Time;
+ }
+ }
+ }
+
+ var passed = recording.UserPassings().ToList();
+ int switches = recording.DriverChanges.Count(change =>
+ recording.CabAt(change.Time + 0.5) is { } s && s.Vehicle == change.Vehicle);
+ // Every timetable the user's train had, as one going on from another at a terminus.
+ var stations = recording.Timetables.SelectMany(timetable => StationProgress.Of(recording, timetable)).ToList();
+ // the largest delay at any station, coming to it or leaving it
+ var delays = stations.SelectMany(s => new[] { s.ArrivalDelay, s.DepartureDelay }).OfType().ToList();
+ var departures = stations.Select(s => s.DepartureDelay).OfType().ToList();
+ var offPlatform = stations.Where(s => s.OnPlatform == false)
+ .Select(s => (StationNames.Display(s.Row.Station), s.OffPlatform ?? 0))
+ .ToList();
+ var horns = recording.UserHornChecks().ToList();
+ return new DrivingSummary(recording.Duration, driven, autopilot, distance, maxSpeed, over, wellOver, maxOver,
+ maxOverAt, passed.Count, passed.Where(p => p.AtDanger).ToList(),
+ recording.UserCommands().Count(), switches, maxAcceleration, maxDeceleration, maxLateral,
+ stations.Count(s => s.Outcome == StationOutcome.Stopped),
+ stations.Count(s => s.Outcome == StationOutcome.Missed),
+ delays.Count > 0 ? delays.Max() : null,
+ horns.Count, horns.Count(h => !h.Sounded), recording.SecurityBrakings().Count)
+ {
+ MaxDepartureDelayMinutes = departures.Count > 0 ? departures.Max() : null,
+ LightsJudgedSeconds = lightsJudged,
+ WrongLightsSeconds = lightsWrong,
+ LightsFaults = faults,
+ OffPlatform = offPlatform
+ };
+ }
+}
+
+/// A stretch the user's train moved with other light signals than it was to show, at its head and its tail.
+public sealed record LightsFault(double Start, double End, HeadCode Head, HeadCode HeadRequired, bool HeadWrong,
+ HeadCode Tail, HeadCode TailRequired, bool TailWrong)
+{
+ public bool SameAs(LightsFault other) =>
+ Head == other.Head && HeadRequired == other.HeadRequired && HeadWrong == other.HeadWrong &&
+ Tail == other.Tail && TailRequired == other.TailRequired && TailWrong == other.TailWrong;
+}
diff --git a/Eu07.BlackBox/Eu07.BlackBox.csproj b/Eu07.BlackBox/Eu07.BlackBox.csproj
new file mode 100644
index 0000000..9f84149
--- /dev/null
+++ b/Eu07.BlackBox/Eu07.BlackBox.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net9.0
+ enable
+ true
+ true
+ true
+ $(NoWarn);CS1591
+ Eu07.BlackBox
+
+
diff --git a/Eu07.BlackBox/HeadCodes.cs b/Eu07.BlackBox/HeadCodes.cs
new file mode 100644
index 0000000..5ddeec0
--- /dev/null
+++ b/Eu07.BlackBox/HeadCodes.cs
@@ -0,0 +1,167 @@
+using System;
+
+namespace Eu07.BlackBox;
+
+/// What the driver of the user's train went by: the lights it is to show depend on it.
+public enum Movement : byte
+{
+ /// No driver, or standing by.
+ Other = 0,
+ /// A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.
+ Train = 1,
+ /// Shunting: Tb 1 at both ends.
+ Shunting = 2,
+ /// A recording made before it was written.
+ Unknown = 0xFF
+}
+
+/// The light signals at an end of a train.
+public enum HeadCode
+{
+ Off,
+ /// The head of a train: both headlights and the upper one.
+ Pc1,
+ /// The head of a train on the wrong track: the left red marker, the right headlight and the upper one.
+ Pc2,
+ /// The tail of a train: both red markers.
+ Pc5,
+ /// Shunting: one headlight.
+ Tb1,
+ /// Any other pattern.
+ Other
+}
+
+/// The light signals a train shows and those it is to show, as the simulator's driver sets them.
+public static class HeadCodes
+{
+ private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
+ Lights.RedMarkerRight | Lights.RearEndSignals;
+
+ /// The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.
+ public static HeadCode Of(Lights lights)
+ {
+ var lit = lights & Signals;
+ if ((lit & Lights.RearEndSignals) != 0)
+ lit = (lit & ~Lights.RearEndSignals) | Lights.RedMarkerLeft | Lights.RedMarkerRight;
+ return lit switch
+ {
+ Lights.None => HeadCode.Off,
+ Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc1,
+ Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc2,
+ Lights.RedMarkerLeft | Lights.RedMarkerRight => HeadCode.Pc5,
+ Lights.HeadlightLeft or Lights.HeadlightRight => HeadCode.Tb1,
+ _ => HeadCode.Other
+ };
+ }
+
+ /// The signal the head of the train is to show, null when nothing tells it.
+ public static HeadCode? RequiredHead(CabSample sample) => sample.Movement switch
+ {
+ Movement.Train => sample.LightHintHead < 0 ? HeadCode.Pc1 : Of((Lights)sample.LightHintHead),
+ Movement.Shunting => HeadCode.Tb1,
+ _ => null
+ };
+
+ /// The signal the tail of the train is to show, null when nothing tells it.
+ public static HeadCode? RequiredTail(CabSample sample) => sample.Movement switch
+ {
+ Movement.Train => sample.LightHintTail < 0 ? HeadCode.Pc5 : Of((Lights)sample.LightHintTail),
+ Movement.Shunting => HeadCode.Tb1,
+ _ => null
+ };
+
+ /// Whether the head of the train showed what it was to; null when nothing tells it.
+ public static bool? HeadRight(CabSample sample) => NoLamps(sample.EquippedHead) ? null : sample.Movement switch
+ {
+ Movement.Train => Meets(sample.LightsHead, sample.EquippedHead, sample.LightHintHead, HeadCode.Pc1),
+ Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsHead, sample.EquippedHead),
+ _ => null
+ };
+
+ /// Whether the tail of the train showed what it was to; null when nothing tells it.
+ public static bool? TailRight(CabSample sample) => NoLamps(sample.EquippedTail) ? null : sample.Movement switch
+ {
+ Movement.Train => Meets(sample.LightsTail, sample.EquippedTail, sample.LightHintTail, HeadCode.Pc5),
+ Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsTail, sample.EquippedTail),
+ _ => null
+ };
+
+ ///
+ /// An end with no lamp at all, a draisine's: the simulator still asks it for a signal it can't show, so it
+ /// is not judged. A recording made before the lamps were written can't tell.
+ ///
+ public static bool NoLamps(Lights? equipped) => equipped == Lights.None;
+
+ /// Whether both ends showed what they were to; null when nothing tells it.
+ public static bool? Right(CabSample sample) =>
+ HeadRight(sample) is { } head && TailRight(sample) is { } tail ? head && tail : null;
+
+ private const Lights HighBeams = Lights.HighBeamLeft | Lights.HighBeamRight;
+ private const Lights Auxiliary = Lights.AuxiliaryLeft | Lights.AuxiliaryRight;
+
+ // the pattern asked for at an end, the usual one when none is: the driver sets one asked for as it is
+ private static bool Meets(Lights lit, Lights? equipped, short hint, HeadCode usual)
+ {
+ if (hint < 0)
+ return Shows(usual, lit, equipped);
+ var pattern = (Lights)hint;
+ if (usual == HeadCode.Pc1 && pattern == (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper))
+ return Shows(HeadCode.Pc2, lit, equipped);
+ // a recording made before the lamps were written: the signal the pattern shows, as before
+ return equipped is { } lamps ? (lit & ~HighBeams) == (pattern & lamps) : Of(lit) == Of(pattern);
+ }
+
+ ///
+ /// Whether the lamps lit at an end show the signal, as the simulator's driver checks it against the lamps
+ /// the vehicle has there: a vehicle without the upper headlight shows Pc 1 with the two others, one with
+ /// auxiliary lamps may show them instead, and one with no lamps for it the rear-end signals. A recording
+ /// made before the lamps were written has the pattern alone. The high beams tell nothing.
+ ///
+ public static bool Shows(HeadCode code, Lights lit, Lights? equipped)
+ {
+ if (equipped is not { } lamps)
+ return Of(lit) == code;
+ lit &= ~HighBeams;
+ bool auxiliary = (lamps & Auxiliary) != 0;
+ switch (code)
+ {
+ case HeadCode.Pc1:
+ {
+ var pattern = lamps & (Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
+ var fallback = lamps & (Auxiliary | Lights.HeadlightUpper);
+ return (pattern != 0 && lit == pattern) || (auxiliary && lit == fallback) ||
+ (pattern == 0 && fallback == 0 && lit == Lights.RearEndSignals);
+ }
+ case HeadCode.Pc2:
+ {
+ var pattern = lamps & (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
+ var fallback = lamps & (Lights.RedMarkerLeft | Lights.AuxiliaryRight | Lights.HeadlightUpper);
+ return lit == pattern || (auxiliary && lit == fallback);
+ }
+ case HeadCode.Pc5:
+ {
+ var pattern = lamps & (Lights.RedMarkerLeft | Lights.RedMarkerRight);
+ var fallback = lamps & Lights.RearEndSignals;
+ return (pattern != 0 && lit == pattern) || (fallback != 0 && lit == fallback);
+ }
+ case HeadCode.Tb1:
+ // one headlight, of those there are
+ return lit == (lamps & Lights.HeadlightLeft) || lit == (lamps & Lights.HeadlightRight);
+ case HeadCode.Off:
+ return lit == Lights.None;
+ default:
+ return false;
+ }
+ }
+
+ /// The name of a signal as the rules give it.
+ public static string Name(HeadCode code) => code switch
+ {
+ HeadCode.Pc1 => "Pc 1",
+ HeadCode.Pc2 => "Pc 2",
+ HeadCode.Pc5 => "Pc 5",
+ HeadCode.Tb1 => "Tb 1",
+ HeadCode.Off => "—",
+ _ => "?"
+ };
+}
diff --git a/Eu07.BlackBox/Recording.cs b/Eu07.BlackBox/Recording.cs
new file mode 100644
index 0000000..50f2fdd
--- /dev/null
+++ b/Eu07.BlackBox/Recording.cs
@@ -0,0 +1,532 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+
+namespace Eu07.BlackBox;
+
+/// A span of time, start included, end not.
+public readonly record struct TimeSpanRange(double Start, double End)
+{
+ public double Length => End - Start;
+ public bool Contains(double t) => t >= Start && t < End;
+}
+
+///
+/// What a black box holds, as far as it has been read: the map, the vehicles and what
+/// happened to them over the time of the recording, which runs from 0 in seconds of
+/// simulation. The lists grow as more of a file still written is read; the queries
+/// answer for any moment of the time read so far.
+///
+public sealed class Recording
+{
+ private readonly List _tracks = new();
+ private readonly Dictionary> _trackIndex = new();
+ private readonly List _signals = new();
+ private readonly Dictionary _signalIndex = new();
+ private readonly Dictionary _vehicles = new();
+ private readonly List _cab = new();
+ private readonly List _looks = new();
+ private readonly Dictionary> _history = new();
+ private readonly List _switches = new();
+ private readonly Dictionary> _switchIndex = new();
+ private readonly List _aspects = new();
+ private readonly Dictionary> _aspectIndex = new();
+ private readonly List _commands = new();
+ private readonly List _driverChanges = new();
+ private readonly List _passings = new();
+ private readonly List _userVehicles = new();
+ private readonly List _clocks = new();
+ private readonly List _stopPoints = new();
+ private readonly List _hornPoints = new();
+ private readonly List _timetables = new();
+ private readonly List _stations = new();
+ private readonly List _hornRequests = new();
+ private readonly List _cameras = new();
+ private readonly List _couplings = new();
+ private double _lastTime;
+
+ public int Version { get; internal set; }
+ public RecordingInfo? Info { get; internal set; }
+
+ /// The time of the end record: null while the simulator still writes, or if it didn't end the recording.
+ public double? EndTime { get; internal set; }
+
+ public bool Complete => EndTime is not null;
+
+ /// Records of types this reader doesn't know, passed over.
+ public int Unknown { get; internal set; }
+
+ /// Records shorter than their type says, passed over.
+ public int Malformed { get; internal set; }
+
+ /// Goes up with every read that added something, for views to tell they have to look again.
+ public int Revision { get; private set; }
+
+ public IReadOnlyList Tracks => _tracks;
+ public IReadOnlyList Signals => _signals;
+ public IReadOnlyDictionary Vehicles => _vehicles;
+ public IReadOnlyList Cab => _cab;
+ public IReadOnlyList Looks => _looks;
+ public IReadOnlyList Switches => _switches;
+ public IReadOnlyList Aspects => _aspects;
+ public IReadOnlyList Commands => _commands;
+ public IReadOnlyList DriverChanges => _driverChanges;
+ public IReadOnlyList Passings => _passings;
+ public IReadOnlyList UserVehicles => _userVehicles;
+ public IReadOnlyList Clocks => _clocks;
+ public IReadOnlyList StopPoints => _stopPoints;
+ public IReadOnlyList HornPoints => _hornPoints;
+ public IReadOnlyList Timetables => _timetables;
+ public IReadOnlyList Stations => _stations;
+ public IReadOnlyList HornRequests => _hornRequests;
+ public IReadOnlyList Cameras => _cameras;
+ public IReadOnlyList Couplings => _couplings;
+
+ /// The time the recording covers so far.
+ public double Duration => Math.Max(EndTime ?? 0, _lastTime);
+
+ /// The seconds between two looks at the vehicles.
+ public double LookInterval => Info is { VehiclesInterval: > 0 } info ? info.VehiclesInterval : 1.0;
+
+ // ----- filled by the reader -----
+
+ internal void Changed()
+ {
+ ++Revision;
+ _memo.Clear();
+ _consists.Clear();
+ }
+
+ // What the questions about the user's train worked out, kept until a read adds something: the views ask them every frame.
+ private readonly Dictionary