mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-11 16:57:11 +01:00
Compare commits
10 Commits
feature/ha
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9872b316c2 | ||
|
|
ed9c1394bd | ||
|
|
0d16524953 | ||
|
|
4dcfd1c4e3 | ||
|
|
9ee059bc16 | ||
|
|
88adf5ee0f | ||
|
|
de3f088ba8 | ||
|
|
532875fc7b | ||
|
|
e530b95352 | ||
| e7d8596652 |
30
Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
Normal file
30
Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- The black box on its own, without the starter: opens the file given on the
|
||||
command line, or one picked. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishAot>true</PublishAot>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\StarterNG\Assets\starter.ico</ApplicationIcon>
|
||||
<AssemblyName>BlackBoxViewer</AssemblyName>
|
||||
<RootNamespace>Eu07.BlackBox.Viewer</RootNamespace>
|
||||
<VersionPrefix>1.0</VersionPrefix>
|
||||
<Company>eu07.pl</Company>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
109
Eu07.BlackBox.Viewer/Program.cs
Normal file
109
Eu07.BlackBox.Viewer/Program.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<ViewerApp>()
|
||||
.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<string>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
29
Eu07.BlackBox.Viewer/Screenshot.cs
Normal file
29
Eu07.BlackBox.Viewer/Screenshot.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>The window as it shows a moment, saved to a PNG; then the viewer closes.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
18
Eu07.BlackBox.Viewer/app.manifest
Normal file
18
Eu07.BlackBox.Viewer/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Eu07.BlackBoxViewer"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
579
Eu07.BlackBox.Views/BlackBoxStrings.cs
Normal file
579
Eu07.BlackBox.Views/BlackBoxStrings.cs
Normal file
@@ -0,0 +1,579 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The texts of the window, by key. The starter gives its own translations; on its
|
||||
/// own the window speaks Polish or English, as the system does.
|
||||
/// </summary>
|
||||
public interface IBlackBoxStrings
|
||||
{
|
||||
string this[string key] { get; }
|
||||
}
|
||||
|
||||
public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
|
||||
{
|
||||
private static readonly Dictionary<string, string> English = new()
|
||||
{
|
||||
["Title"] = "Black box",
|
||||
["TitleOf"] = "Black box – {0}",
|
||||
["Open"] = "Open…",
|
||||
["Reload"] = "Reload",
|
||||
["Live"] = "Follow the file",
|
||||
["LiveTip"] = "Reads what the simulator adds to the file every 2 seconds",
|
||||
["Play"] = "Play",
|
||||
["Pause"] = "Pause",
|
||||
["Follow"] = "Follow the train",
|
||||
["Fit"] = "Whole map",
|
||||
["NoFile"] = "Open a black box: physicslog/*.blackbox, written with the physics logs on.",
|
||||
["Unreadable"] = "Can't read {0}: {1}",
|
||||
["Reading"] = "Reading {0}…",
|
||||
["Summary"] = "{0} · {1:0.0} km · max {2:0} km/h · driven {3} · autopilot {4} · over the AI {5} ({6} by more than {7:0} km/h, at most +{8:0} km/h) · signals {9} · at danger {10} · commands {11}",
|
||||
["NotComplete"] = "still being written, or cut short",
|
||||
["Cursor"] = "{0} · {1:0} km/h · AI {2} · limit {3}",
|
||||
["CursorNoAi"] = "{0} · {1:0} km/h · no AI data",
|
||||
["CursorOut"] = "{0} · not in a vehicle",
|
||||
["None"] = "–",
|
||||
["LimitAt"] = "{0:0} km/h in {1:0} m",
|
||||
["EventCommand"] = "{0}",
|
||||
["EventAutopilotOn"] = "Autopilot on",
|
||||
["EventAutopilotOff"] = "Autopilot off",
|
||||
["EventPassed"] = "Signal {0}: {1}, {2:0} km/h",
|
||||
["EventDanger"] = "PASSED AT DANGER {0}, {1:0} km/h, {2:0} m beyond",
|
||||
["EventInVehicle"] = "In {0}",
|
||||
["EventOutside"] = "Out of the cab of {0}",
|
||||
["EventNoVehicle"] = "Not in any vehicle",
|
||||
["EventEnd"] = "End of the recording",
|
||||
["AspectStop"] = "stop",
|
||||
["AspectFree"] = "go",
|
||||
["AspectSpeed"] = "{0:0}/{1}",
|
||||
["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<string, string> Polish = new()
|
||||
{
|
||||
["Title"] = "Czarna skrzynka",
|
||||
["TitleOf"] = "Czarna skrzynka – {0}",
|
||||
["Open"] = "Otwórz…",
|
||||
["Reload"] = "Odśwież",
|
||||
["Live"] = "Śledź plik",
|
||||
["LiveTip"] = "Co 2 sekundy doczytuje to, co symulator dopisał do pliku",
|
||||
["Play"] = "Odtwarzaj",
|
||||
["Pause"] = "Pauza",
|
||||
["Follow"] = "Śledź pociąg",
|
||||
["Fit"] = "Cała mapa",
|
||||
["NoFile"] = "Otwórz czarną skrzynkę: physicslog/*.blackbox, zapisywaną razem z logami fizyki.",
|
||||
["Unreadable"] = "Nie można odczytać {0}: {1}",
|
||||
["Reading"] = "Czytanie {0}…",
|
||||
["Summary"] = "{0} · {1:0.0} km · maks. {2:0} km/h · jazda {3} · autopilot {4} · ponad AI {5} (w tym {6} o więcej niż {7:0} km/h, najwyżej +{8:0} km/h) · sygnalizatory {9} · minięte na „Stój” {10} · komendy {11}",
|
||||
["NotComplete"] = "wciąż zapisywana albo ucięta",
|
||||
["Cursor"] = "{0} · {1:0} km/h · AI {2} · ograniczenie {3}",
|
||||
["CursorNoAi"] = "{0} · {1:0} km/h · brak danych AI",
|
||||
["CursorOut"] = "{0} · poza pojazdem",
|
||||
["None"] = "–",
|
||||
["LimitAt"] = "{0:0} km/h za {1:0} m",
|
||||
["EventCommand"] = "{0}",
|
||||
["EventAutopilotOn"] = "Autopilot włączony",
|
||||
["EventAutopilotOff"] = "Autopilot wyłączony",
|
||||
["EventPassed"] = "Sygnalizator {0}: {1}, {2:0} km/h",
|
||||
["EventDanger"] = "MINIĘTY NA „STÓJ” {0}, {1:0} km/h, {2:0} m za nim",
|
||||
["EventInVehicle"] = "W {0}",
|
||||
["EventOutside"] = "Poza kabiną {0}",
|
||||
["EventNoVehicle"] = "Poza pojazdami",
|
||||
["EventEnd"] = "Koniec nagrania",
|
||||
["AspectStop"] = "stój",
|
||||
["AspectFree"] = "wolna droga",
|
||||
["AspectSpeed"] = "{0:0}/{1}",
|
||||
["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<string, string> _texts;
|
||||
|
||||
public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
|
||||
{
|
||||
}
|
||||
|
||||
public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Polish for "pl", English for any other language.</summary>
|
||||
public DefaultBlackBoxStrings(string languageCode)
|
||||
{
|
||||
_texts = languageCode == "pl" ? Polish : English;
|
||||
}
|
||||
|
||||
/// <summary>The keys there are texts for, for a translation to give them all.</summary>
|
||||
public static IEnumerable<string> Keys => English.Keys;
|
||||
|
||||
public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
|
||||
}
|
||||
571
Eu07.BlackBox.Views/BlackBoxWindow.cs
Normal file
571
Eu07.BlackBox.Views/BlackBoxWindow.cs
Normal file
@@ -0,0 +1,571 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Replays a black box. Above: the train and the scenery, playing and how late the train
|
||||
/// was. In the middle the map at the moment played, the timeline of the user's train under
|
||||
/// it; beside them the events, the timetable or the figures of the driving, and the cab. A file
|
||||
/// the simulator still writes can be followed: what it adds is read every two seconds, and
|
||||
/// playing at its end keeps up with it.
|
||||
/// </summary>
|
||||
public sealed class BlackBoxWindow : Window
|
||||
{
|
||||
private static readonly double[] Speeds = { 1, 2, 5, 10, 30, 60 };
|
||||
|
||||
private readonly IBlackBoxStrings _strings;
|
||||
private readonly MapView _map;
|
||||
private readonly TimelineView _timeline;
|
||||
private readonly EventList _events;
|
||||
private readonly TimetablePanel _timetable;
|
||||
private readonly ScrollViewer _timetableScroll;
|
||||
private readonly ScrollViewer _summaryScroll;
|
||||
private readonly CabPanel _cab;
|
||||
private readonly UiKit.Segmented _tabs;
|
||||
private readonly TextBlock _title = new() { FontSize = 14, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _subtitle = new() { FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _clock = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, MinWidth = 86, TextAlignment = TextAlignment.Center };
|
||||
private readonly TextBlock _delayText = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 };
|
||||
private readonly TextBlock _readout = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _fileState = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.FaintText };
|
||||
private readonly TextBlock _message = new() { FontSize = 14, Foreground = Palette.DimText, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Margin = new Thickness(40) };
|
||||
private readonly StackPanel _tiles = new();
|
||||
private readonly UiKit.Tile _distance, _maxSpeed, _driven, _overAi, _signals, _stops, _delay, _platform, _lights, _horn, _acceleration, _security, _state;
|
||||
private readonly Button _open;
|
||||
private readonly Button _play;
|
||||
private readonly ComboBox _speed = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 64, FontSize = 12, Padding = new Thickness(8, 3) };
|
||||
private readonly Avalonia.Controls.Primitives.ToggleButton _follow;
|
||||
private readonly Avalonia.Controls.Primitives.ToggleButton _live;
|
||||
private readonly DispatcherTimer _tick = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
||||
private readonly DispatcherTimer _reload = new() { Interval = TimeSpan.FromSeconds(2) };
|
||||
private readonly Stopwatch _played = new();
|
||||
|
||||
private BlackBoxReader? _reader;
|
||||
private string? _path;
|
||||
private double _time;
|
||||
private bool _reading;
|
||||
|
||||
public BlackBoxWindow() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public BlackBoxWindow(IBlackBoxStrings? strings)
|
||||
{
|
||||
_strings = strings ?? new DefaultBlackBoxStrings();
|
||||
Title = _strings["Title"];
|
||||
Width = 1440;
|
||||
Height = 900;
|
||||
MinWidth = 960;
|
||||
MinHeight = 600;
|
||||
Background = Palette.Background;
|
||||
UiKit.Accent(this);
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
|
||||
_map = new MapView { Strings = _strings };
|
||||
_timeline = new TimelineView { Strings = _strings };
|
||||
_events = new EventList { Strings = _strings, Background = Brushes.Transparent };
|
||||
_timetable = new TimetablePanel { Strings = _strings };
|
||||
_timetableScroll = new ScrollViewer { Content = _timetable, IsVisible = false };
|
||||
_summaryScroll = new ScrollViewer { Content = _tiles, IsVisible = false };
|
||||
_cab = new CabPanel { Strings = _strings };
|
||||
|
||||
// ----- the bar on top: the train, playing, the switches -----
|
||||
_open = UiKit.Button(_strings["Open"]);
|
||||
_open.Click += async (_, _) => await PickAsync();
|
||||
var reload = UiKit.Button(_strings["Reload"]);
|
||||
reload.Click += (_, _) => ReadMore();
|
||||
var fit = UiKit.Button(_strings["Fit"]);
|
||||
fit.Click += (_, _) => ShowWhole();
|
||||
_follow = UiKit.Toggle(_strings["Follow"]);
|
||||
_follow.IsCheckedChanged += (_, _) => _map.Follow = _follow.IsChecked == true;
|
||||
_map.FollowStopped += () => _follow.IsChecked = false;
|
||||
_live = UiKit.Toggle(_strings["Live"], _strings["LiveTip"]);
|
||||
_live.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
if (_live.IsChecked == true)
|
||||
_reload.Start();
|
||||
else
|
||||
_reload.Stop();
|
||||
};
|
||||
|
||||
_play = UiKit.PlayButton();
|
||||
_play.Content = UiKit.PlayGlyph(false);
|
||||
ToolTip.SetTip(_play, _strings["Play"]);
|
||||
_play.Click += (_, _) => Playing = !Playing;
|
||||
_speed.ItemsSource = Speeds.Select(s => $"×{s.ToString(CultureInfo.InvariantCulture)}").ToArray();
|
||||
_speed.SelectedIndex = 3;
|
||||
|
||||
var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Children = { _title, _subtitle } };
|
||||
var transport = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, VerticalAlignment = VerticalAlignment.Center, Children = { _play, _clock, _speed, _delayText } };
|
||||
var switches = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { _follow, _live, fit, reload, _open } };
|
||||
var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") };
|
||||
Place(header, titles, 0, 0);
|
||||
Place(header, transport, 0, 1);
|
||||
switches.HorizontalAlignment = HorizontalAlignment.Right;
|
||||
Place(header, switches, 0, 2);
|
||||
titles.Margin = new Thickness(0, 0, 16, 0);
|
||||
var headerBar = new Border
|
||||
{
|
||||
Background = Palette.Panel,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(8, 0),
|
||||
Height = 44,
|
||||
Child = header
|
||||
};
|
||||
|
||||
// ----- the figures, a table in a tab of their own -----
|
||||
_distance = Tile("StatDistance");
|
||||
_maxSpeed = Tile("StatMaxSpeed");
|
||||
_driven = Tile("StatDriven");
|
||||
_overAi = Tile("StatOverAi");
|
||||
_signals = Tile("StatSignals");
|
||||
_stops = Tile("StatStops");
|
||||
_delay = Tile("StatDelay");
|
||||
_platform = Tile("StatPlatform");
|
||||
_lights = Tile("StatLights");
|
||||
_horn = Tile("StatHorn");
|
||||
_acceleration = Tile("StatAcceleration");
|
||||
_security = Tile("StatSecurity");
|
||||
_state = Tile("StatRecording");
|
||||
|
||||
_timeline.Seek += t => SetTime(t);
|
||||
_timeline.Hovered += ShowReadout;
|
||||
_events.Seek += t => SetTime(t);
|
||||
_timetable.Seek += t => SetTime(t);
|
||||
_tick.Tick += (_, _) => Advance();
|
||||
_reload.Tick += (_, _) => ReadMore();
|
||||
|
||||
// ----- the map with its zoom, the timeline, the side -----
|
||||
var zoomIn = MapButton("+", _strings["ZoomIn"], () => _map.ZoomBy(1.6));
|
||||
var zoomOut = MapButton("−", _strings["ZoomOut"], () => _map.ZoomBy(1 / 1.6));
|
||||
var zoomFit = MapButton("⤢", _strings["Fit"], ShowWhole);
|
||||
var zoom = new StackPanel
|
||||
{
|
||||
Spacing = 1,
|
||||
Margin = new Thickness(8),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Children = { zoomIn, zoomOut, zoomFit }
|
||||
};
|
||||
var mapArea = new Panel { Children = { _map, zoom, _message } };
|
||||
_map.ClipToBounds = true;
|
||||
|
||||
_tabs = new UiKit.Segmented(_strings["TabEvents"], _strings["TabTimetable"], _strings["TabSummary"]);
|
||||
_tabs.SelectedChanged += index =>
|
||||
{
|
||||
_events.IsVisible = index == 0;
|
||||
_timetableScroll.IsVisible = index == 1;
|
||||
_summaryScroll.IsVisible = index == 2;
|
||||
};
|
||||
var side = new DockPanel();
|
||||
DockPanel.SetDock(_tabs, Dock.Top);
|
||||
side.Children.Add(_tabs);
|
||||
side.Children.Add(new Panel { Children = { _events, _timetableScroll, _summaryScroll } });
|
||||
var cabCard = UiKit.Group(_strings["Cab"], 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<string>();
|
||||
if (fault.HeadWrong)
|
||||
ends.Add(string.Format(culture, _strings["StatLightsHead"], HeadCodes.Name(fault.Head), HeadCodes.Name(fault.HeadRequired)));
|
||||
if (fault.TailWrong)
|
||||
ends.Add(string.Format(culture, _strings["StatLightsTail"], HeadCodes.Name(fault.Tail), HeadCodes.Name(fault.TailRequired)));
|
||||
return $"{Clock(recording.ClockAt(fault.Start).TotalSeconds)} {string.Join(", ", ends)}";
|
||||
}
|
||||
|
||||
private UiKit.Tile Tile(string key)
|
||||
{
|
||||
var tile = new UiKit.Tile(_strings[key]);
|
||||
_tiles.Children.Add(tile);
|
||||
return tile;
|
||||
}
|
||||
|
||||
private static Button MapButton(string text, string tip, Action action)
|
||||
{
|
||||
var button = UiKit.Button(text, tip);
|
||||
button.Width = 28;
|
||||
button.Height = 28;
|
||||
button.Padding = new Thickness(0);
|
||||
button.FontSize = 15;
|
||||
button.HorizontalContentAlignment = HorizontalAlignment.Center;
|
||||
button.VerticalContentAlignment = VerticalAlignment.Center;
|
||||
button.Background = Palette.Panel;
|
||||
button.Click += (_, _) => action();
|
||||
return button;
|
||||
}
|
||||
|
||||
private static void Place(Grid grid, Control control, int row, int column)
|
||||
{
|
||||
Grid.SetRow(control, row);
|
||||
Grid.SetColumn(control, column);
|
||||
grid.Children.Add(control);
|
||||
}
|
||||
|
||||
/// <summary>The button for opening another file: the starter opens the one picked in its list.</summary>
|
||||
public bool CanOpenFiles
|
||||
{
|
||||
get => _open.IsVisible;
|
||||
set => _open.IsVisible = value;
|
||||
}
|
||||
|
||||
/// <summary>Reads what the simulator adds to the file every two seconds.</summary>
|
||||
public bool Live
|
||||
{
|
||||
get => _live.IsChecked == true;
|
||||
set => _live.IsChecked = value;
|
||||
}
|
||||
|
||||
public string? FilePath => _path;
|
||||
|
||||
public Recording? Recording => _reader?.Recording;
|
||||
|
||||
public double CurrentTime => _time;
|
||||
|
||||
/// <summary>Goes to a moment of the recording, in seconds from its start.</summary>
|
||||
public void SeekTo(double t) => SetTime(t);
|
||||
|
||||
/// <summary>Keeps the user's train in the middle of the map.</summary>
|
||||
public bool FollowTrain
|
||||
{
|
||||
get => _follow.IsChecked == true;
|
||||
set => _follow.IsChecked = value;
|
||||
}
|
||||
|
||||
/// <summary>The timetable beside the map instead of the events.</summary>
|
||||
public bool ShowTimetable
|
||||
{
|
||||
get => _tabs.Selected == 1;
|
||||
set => _tabs.Selected = value ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>The summary of the driving beside the map instead of the events.</summary>
|
||||
public bool SummaryShown
|
||||
{
|
||||
get => _tabs.Selected == 2;
|
||||
set => _tabs.Selected = value ? 2 : 0;
|
||||
}
|
||||
|
||||
private bool Playing
|
||||
{
|
||||
get => _tick.IsEnabled;
|
||||
set
|
||||
{
|
||||
if (value == _tick.IsEnabled)
|
||||
return;
|
||||
if (value)
|
||||
{
|
||||
if (Recording is { } r && _time >= r.Duration - 0.05 && !Live)
|
||||
SetTime(0);
|
||||
_played.Restart();
|
||||
_tick.Start();
|
||||
}
|
||||
else
|
||||
_tick.Stop();
|
||||
_play.Content = UiKit.PlayGlyph(value);
|
||||
ToolTip.SetTip(_play, _strings[value ? "Pause" : "Play"]);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowWhole()
|
||||
{
|
||||
_follow.IsChecked = false;
|
||||
_map.Fit();
|
||||
_timeline.Fit();
|
||||
}
|
||||
|
||||
/// <summary>Opens a black box, read whole on another thread.</summary>
|
||||
public async Task OpenAsync(string path)
|
||||
{
|
||||
Playing = false;
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Reading"], Path.GetFileName(path)));
|
||||
try
|
||||
{
|
||||
var reader = await Task.Run(() =>
|
||||
{
|
||||
var r = new BlackBoxReader();
|
||||
r.ReadMore(path);
|
||||
return r;
|
||||
});
|
||||
_reader = reader;
|
||||
_path = path;
|
||||
Title = string.Format(CultureInfo.CurrentCulture, _strings["TitleOf"], Path.GetFileName(path));
|
||||
_map.Recording = reader.Recording;
|
||||
_timeline.Recording = reader.Recording;
|
||||
_events.Recording = reader.Recording;
|
||||
_timetable.Recording = reader.Recording;
|
||||
_cab.Recording = reader.Recording;
|
||||
_message.IsVisible = false;
|
||||
// A file still written opens at its end, where the train is now.
|
||||
SetTime(Live ? reader.Recording.Duration : 0);
|
||||
ShowSummary();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], path, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowMessage(string text)
|
||||
{
|
||||
_message.Text = text;
|
||||
_message.IsVisible = true;
|
||||
_subtitle.Text = text;
|
||||
_fileState.Text = "";
|
||||
}
|
||||
|
||||
/// <summary>What the simulator added since the last read. Small, so read here, between frames.</summary>
|
||||
public void ReadMore()
|
||||
{
|
||||
if (_reader is not { } reader || _path is null || _reading)
|
||||
return;
|
||||
_reading = true;
|
||||
try
|
||||
{
|
||||
bool atEnd = _time >= reader.Recording.Duration - 0.5;
|
||||
if (reader.ReadMore(_path) == 0)
|
||||
return;
|
||||
_events.Refresh();
|
||||
if (atEnd && !Playing)
|
||||
SetTime(reader.Recording.Duration);
|
||||
else
|
||||
SetTime(_time);
|
||||
ShowSummary();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], _path, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PickAsync()
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = _strings["Open"],
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[] { new FilePickerFileType(_strings["FileFilter"]) { Patterns = new[] { "*.blackbox" } } }
|
||||
});
|
||||
if (files.Count > 0 && files[0].TryGetLocalPath() is { } path)
|
||||
await OpenAsync(path);
|
||||
}
|
||||
|
||||
private void Advance()
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
double real = _played.Elapsed.TotalSeconds;
|
||||
_played.Restart();
|
||||
double speed = Speeds[Math.Clamp(_speed.SelectedIndex, 0, Speeds.Length - 1)];
|
||||
double next = _time + real * speed;
|
||||
if (next >= recording.Duration)
|
||||
{
|
||||
next = recording.Duration;
|
||||
// At the end of a file still written it waits for more.
|
||||
if (!Live)
|
||||
Playing = false;
|
||||
}
|
||||
SetTime(next);
|
||||
}
|
||||
|
||||
private void SetTime(double t)
|
||||
{
|
||||
var recording = Recording;
|
||||
double duration = recording?.Duration ?? 0;
|
||||
_time = Math.Clamp(t, 0, duration);
|
||||
_map.Time = _time;
|
||||
_timeline.Time = _time;
|
||||
_cab.Time = _time;
|
||||
_timetable.Time = _time;
|
||||
if (recording is null)
|
||||
return;
|
||||
_clock.Text = recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
_events.ShowTime(_time);
|
||||
ShowReadout(null);
|
||||
ShowDelay(recording);
|
||||
}
|
||||
|
||||
/// <summary>How late the user's train was at the moment played, by the clock.</summary>
|
||||
private void ShowDelay(Recording recording)
|
||||
{
|
||||
if (StationProgress.DelayAt(recording, _time) is not { } delay)
|
||||
{
|
||||
_delayText.Text = "";
|
||||
return;
|
||||
}
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
var brush = Palette.Delay(delay.Minutes);
|
||||
_delayText.Text = Math.Abs(delay.Minutes) < 0.5
|
||||
? _strings["OnTime"]
|
||||
: string.Format(culture, _strings["DelayNow"], string.Format(culture, _strings["Minutes"], delay.Minutes));
|
||||
_delayText.Foreground = brush;
|
||||
ToolTip.SetTip(_delayText, string.Format(culture, _strings[delay.Standing ? "DelayStanding" : "DelayAfter"], delay.Train,
|
||||
StationNames.Display(delay.Station)));
|
||||
}
|
||||
|
||||
/// <summary>The user's train at the moment under the pointer of the timeline, or at the moment played.</summary>
|
||||
private void ShowReadout(double? hovered)
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
double t = hovered ?? _time;
|
||||
string clock = recording.ClockAt(t).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
if (recording.CabAt(t) is not { } s || t - s.Time > 1.0)
|
||||
{
|
||||
_readout.Text = string.Format(culture, _strings["CursorOut"], clock);
|
||||
return;
|
||||
}
|
||||
if (!s.AiValid)
|
||||
{
|
||||
_readout.Text = string.Format(culture, _strings["CursorNoAi"], clock, s.AbsSpeedKmh);
|
||||
return;
|
||||
}
|
||||
string limit = s.Ai.HasNextLimit
|
||||
? string.Format(culture, _strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance)
|
||||
: _strings["None"];
|
||||
_readout.Text = string.Format(culture, _strings["Cursor"], clock, s.AbsSpeedKmh,
|
||||
s.Ai.TargetKmh.ToString("0", culture) + " km/h", limit);
|
||||
}
|
||||
|
||||
private void ShowSummary()
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
var summary = recording.Summarize();
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
|
||||
// The scenery, the train and the vehicle the user started in.
|
||||
string scenery = recording.Info?.Scenery ?? Path.GetFileName(_path) ?? "";
|
||||
_title.Text = Path.GetFileNameWithoutExtension(scenery.TrimStart('$'));
|
||||
var timetable = recording.TimetableAt(_time);
|
||||
string vehicle = recording.Cab.Count > 0 ? recording.Vehicle(recording.Cab[^1].Vehicle)?.Name ?? "" : recording.Info?.StartVehicle ?? "";
|
||||
var parts = new[]
|
||||
{
|
||||
timetable is null ? null : $"{timetable.Category} {timetable.Train}".Trim(),
|
||||
timetable is null || timetable.From.Length + timetable.To.Length == 0 ? null : string.Format(culture, _strings["Relation"], timetable.From, timetable.To),
|
||||
vehicle,
|
||||
recording.Info?.Started.Replace('T', ' ')
|
||||
};
|
||||
_subtitle.Text = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
_distance.Show($"{summary.DistanceKm:0.0} km");
|
||||
_maxSpeed.Show($"{summary.MaxSpeedKmh:0} km/h");
|
||||
_driven.Show(Clock(summary.DrivenSeconds), string.Format(culture, _strings["StatAutopilotSub"], Clock(summary.AutopilotSeconds)));
|
||||
_overAi.Show(Clock(summary.OverAiSeconds),
|
||||
string.Format(culture, _strings["StatOverAiSub"], Clock(summary.WellOverAiSeconds), DrivingSummary.DefaultTolerance, summary.MaxOverAiKmh),
|
||||
summary.WellOverAiSeconds > 0 ? Palette.Bad : summary.OverAiSeconds > 0 ? Palette.Warn : Palette.Good);
|
||||
_signals.Show(summary.SignalsPassed.ToString(culture), string.Format(culture, _strings["StatSignalsSub"], summary.PassedAtDanger.Count),
|
||||
summary.PassedAtDanger.Count > 0 ? Palette.Bad : null);
|
||||
_stops.IsVisible = recording.Timetables.Count > 0;
|
||||
_stops.Show(summary.Stops.ToString(culture), string.Format(culture, _strings["StatStopsSub"], summary.StopsMissed),
|
||||
summary.StopsMissed > 0 ? Palette.Bad : null);
|
||||
_delay.IsVisible = summary.MaxDelayMinutes is not null;
|
||||
if (summary.MaxDelayMinutes is { } delay)
|
||||
_delay.Show(string.Format(culture, _strings["Minutes"], delay),
|
||||
summary.MaxDepartureDelayMinutes is { } departure
|
||||
? string.Format(culture, _strings["StatDelaySub"], string.Format(culture, _strings["Minutes"], departure))
|
||||
: null,
|
||||
Palette.Delay(delay));
|
||||
_platform.IsVisible = summary.Stops > 0;
|
||||
_platform.Show(summary.OffPlatform.Count.ToString(culture),
|
||||
summary.OffPlatform.Count > 0
|
||||
? string.Join(" · ", summary.OffPlatform.Select(p => string.Format(culture, _strings["StatPlatformAt"], p.Station, p.Metres)))
|
||||
: _strings["StatPlatformNone"],
|
||||
summary.OffPlatform.Count > 0 ? Palette.Bad : Palette.Good);
|
||||
_lights.IsVisible = summary.LightsJudgedSeconds > 0;
|
||||
_lights.Show(summary.WrongLightsSeconds > 0 ? Clock(summary.WrongLightsSeconds) : _strings["StatLightsRight"],
|
||||
summary.LightsFaults.Count > 0
|
||||
? string.Join(" · ", summary.LightsFaults.Take(3).Select(f => LightsFaultText(recording, f, culture)))
|
||||
+ (summary.LightsFaults.Count > 3 ? " …" : "")
|
||||
: string.Format(culture, _strings["StatLightsSub"], Clock(summary.LightsJudgedSeconds)),
|
||||
summary.WrongLightsSeconds > 0 ? Palette.Bad : Palette.Good);
|
||||
_horn.IsVisible = summary.HornPoints > 0;
|
||||
_horn.Show($"{summary.HornPoints - summary.HornsMissed}/{summary.HornPoints}", string.Format(culture, _strings["StatHornSub"], summary.HornsMissed),
|
||||
summary.HornsMissed > 0 ? Palette.Bad : Palette.Good);
|
||||
_acceleration.Show($"+{summary.MaxAcceleration:0.00} / −{summary.MaxDeceleration:0.00}",
|
||||
string.Format(culture, _strings["StatAccelerationSub"], summary.MaxLateralAcceleration));
|
||||
_security.Show(summary.SecurityBrakings.ToString(culture), null, summary.SecurityBrakings > 0 ? Palette.Bad : Palette.Good);
|
||||
_state.Show(Clock(recording.Duration), _strings[recording.Complete ? "StatComplete" : "StatIncomplete"],
|
||||
recording.Complete ? null : Palette.Warn);
|
||||
_fileState.Text = $"{Path.GetFileName(_path)} · {Clock(recording.Duration)} · {_strings[recording.Complete ? "StatComplete" : "StatIncomplete"]}";
|
||||
}
|
||||
|
||||
private static string Clock(double seconds)
|
||||
{
|
||||
var span = TimeSpan.FromSeconds(Math.Round(seconds));
|
||||
return span.TotalHours >= 1
|
||||
? span.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
|
||||
: span.ToString(@"m\:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
567
Eu07.BlackBox.Views/CabPanel.cs
Normal file
567
Eu07.BlackBox.Views/CabPanel.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int> _powered = new();
|
||||
private readonly HashSet<int> _feedPipe = new();
|
||||
private readonly HashSet<int> _integrated = new();
|
||||
private readonly HashSet<int> _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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The setting of the brakes from its bits: G, P, R, R+Mg.</summary>
|
||||
public static string BrakeSetting(byte bits)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The mode of the brakes from its bits: PS, PN, EP, MED.</summary>
|
||||
public static string BrakeMode(byte bits)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>The steps of the controller as a row of cells, lit up to the one it stands at.</summary>
|
||||
private static void Steps(DrawingContext context, int step, int top, double x, double y, double width)
|
||||
{
|
||||
int cells = Math.Clamp(top, 1, 40);
|
||||
double cell = width / cells;
|
||||
for (int i = 0; i < cells; i++)
|
||||
context.DrawRectangle(i < step ? Palette.Controller : Palette.LampOff, null,
|
||||
new Rect(x + i * cell, y, Math.Max(1, cell - (cells > 20 ? 0.5 : 1.5)), 4), 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.</summary>
|
||||
private static void Gauge(DrawingContext context, string label, float value, double scale, double? mark, string text,
|
||||
IBrush brush, double x, double y, double width)
|
||||
{
|
||||
const double labelWidth = 118, valueWidth = 64;
|
||||
context.DrawText(MapView.Text(label, Palette.DimText, 11), new Point(x, y));
|
||||
double barX = x + labelWidth, barWidth = width - labelWidth - valueWidth;
|
||||
var track = new Rect(barX, y + 5, barWidth, 6);
|
||||
context.DrawRectangle(Palette.LampOff, null, track, 3, 3);
|
||||
double filled = Math.Clamp(value / scale, 0, 1) * barWidth;
|
||||
if (filled > 0.5)
|
||||
context.DrawRectangle(brush, null, new Rect(barX, y + 5, filled, 6), 3, 3);
|
||||
if (mark is { } m)
|
||||
{
|
||||
double mx = barX + m / scale * barWidth;
|
||||
context.DrawLine(new Pen(Palette.Text, 1.5), new Point(mx, y + 2), new Point(mx, y + 14));
|
||||
}
|
||||
var v = MapView.Text(text, Palette.Text, 11, FontWeight.SemiBold);
|
||||
context.DrawText(v, new Point(x + width - v.Width, y));
|
||||
}
|
||||
|
||||
/// <summary>A value either way of nought: a bar from the middle, to the right above it.</summary>
|
||||
private static void Balance(DrawingContext context, string label, float value, double scale, double x, double y, double width)
|
||||
{
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
|
||||
var v = MapView.Text($"{value.ToString("+0.00;−0.00;0.00", culture)} m/s²", Palette.Text, 11, FontWeight.SemiBold);
|
||||
context.DrawText(v, new Point(x + width - v.Width, y));
|
||||
double barY = y + 17, middle = x + width / 2;
|
||||
context.DrawRectangle(Palette.LampOff, null, new Rect(x, barY, width, 5), 2.5, 2.5);
|
||||
double length = Math.Clamp(value / scale, -1, 1) * width / 2;
|
||||
var brush = Math.Abs(value) > scale * 0.7 ? Palette.Bad : value >= 0 ? Palette.Good : Palette.Brakes;
|
||||
if (Math.Abs(length) > 0.5)
|
||||
context.DrawRectangle(brush, null, new Rect(Math.Min(middle, middle + length), barY, Math.Abs(length), 5), 2.5, 2.5);
|
||||
context.DrawLine(new Pen(Palette.DimText, 1), new Point(middle, barY - 2), new Point(middle, barY + 7));
|
||||
}
|
||||
|
||||
/// <summary>A chip ending at x, its width returned.</summary>
|
||||
private static double ChipLeft(DrawingContext context, string text, IBrush brush, double right, double y)
|
||||
{
|
||||
var label = MapView.Text(text, brush, 11, FontWeight.SemiBold);
|
||||
var box = new Rect(right - label.Width - 12, y, label.Width + 12, label.Height + 4);
|
||||
context.DrawRectangle(Palette.Faded(brush, 0.14), null, box, 4, 4);
|
||||
context.DrawText(label, new Point(box.X + 6, y + 2));
|
||||
return box.Width;
|
||||
}
|
||||
|
||||
/// <summary>The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.</summary>
|
||||
private static void DrawFace(DrawingContext context, Lights lights, Point center)
|
||||
{
|
||||
context.DrawRectangle(null, new Pen(Palette.Grid, 1), new Rect(center.X - 17, center.Y - 15, 34, 22), 5, 5);
|
||||
bool rearEnd = lights.HasFlag(Lights.RearEndSignals);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A device's lamp and its name: lit in its colour, the name bright; off, both faint.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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())];
|
||||
}
|
||||
|
||||
/// <summary>The doors of the vehicle the user is in, in rows of two: open, the lock, the permits, the steps, the departure signal.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
20
Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
Normal file
20
Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- The window that replays a black box: the map, the timeline, the events. Code
|
||||
only, no XAML loaded at run time, so it goes through NativeAOT and trimming
|
||||
in the starter and in the viewer alike. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<RootNamespace>Eu07.BlackBox.Views</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
382
Eu07.BlackBox.Views/EventList.cs
Normal file
382
Eu07.BlackBox.Views/EventList.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>One line of the list of events.</summary>
|
||||
public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
|
||||
|
||||
/// <summary>
|
||||
/// What happened to the user's train, by the clock: the commands, the autopilot, the
|
||||
/// signals passed, the stations, the horn points, the couplings, the train protection,
|
||||
/// the cab and the camera of the user. A click goes to its moment.
|
||||
/// </summary>
|
||||
public sealed class EventList : ListBox
|
||||
{
|
||||
private Recording? _recording;
|
||||
private int _shownRevision = -1;
|
||||
private List<BlackBoxEvent> _events = new();
|
||||
private bool _picking;
|
||||
|
||||
public event Action<double>? Seek;
|
||||
|
||||
// The look of a list box: a control of a type of its own has no theme otherwise, and shows nothing.
|
||||
protected override Type StyleKeyOverride => typeof(ListBox);
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public EventList()
|
||||
{
|
||||
SelectionMode = SelectionMode.Single;
|
||||
Padding = new Thickness(4, 0, 4, 6);
|
||||
// The picked line in the starter's green.
|
||||
Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
|
||||
Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
|
||||
Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
|
||||
Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
|
||||
SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (!_picking && SelectedIndex >= 0 && SelectedIndex < _events.Count)
|
||||
Seek?.Invoke(_events[SelectedIndex].Time);
|
||||
};
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_shownRevision = -1;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<BlackBoxEvent> Events => _events;
|
||||
|
||||
/// <summary>Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_recording is not { } recording)
|
||||
{
|
||||
_events.Clear();
|
||||
Items.Clear();
|
||||
return;
|
||||
}
|
||||
if (recording.Revision == _shownRevision)
|
||||
return;
|
||||
_shownRevision = recording.Revision;
|
||||
|
||||
var events = Collect(recording, Strings);
|
||||
bool grown = events.Count >= _events.Count &&
|
||||
_events.Select((e, i) => e == events[i]).All(same => same);
|
||||
if (!grown)
|
||||
Items.Clear();
|
||||
for (int i = grown ? _events.Count : 0; i < events.Count; i++)
|
||||
Items.Add(new ListBoxItem
|
||||
{
|
||||
Content = Line(recording, events[i]),
|
||||
Padding = new Thickness(8, 4),
|
||||
MinHeight = 0,
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Margin = new Thickness(0, 1)
|
||||
});
|
||||
_events = events;
|
||||
}
|
||||
|
||||
/// <summary>The event at or just before t picked, without going to it.</summary>
|
||||
public void ShowTime(double t)
|
||||
{
|
||||
int index = -1;
|
||||
for (int i = 0; i < _events.Count && _events[i].Time <= t + 0.05; i++)
|
||||
index = i;
|
||||
if (index == SelectedIndex)
|
||||
return;
|
||||
_picking = true;
|
||||
try
|
||||
{
|
||||
SelectedIndex = index;
|
||||
if (index >= 0)
|
||||
ScrollIntoView(index);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Control Line(Recording recording, BlackBoxEvent e)
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("58,14,*") };
|
||||
var time = new TextBlock
|
||||
{
|
||||
Text = recording.ClockAt(e.Time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
Foreground = Palette.FaintText,
|
||||
FontSize = 11.5,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, 1, 0, 0)
|
||||
};
|
||||
var dot = new Border
|
||||
{
|
||||
Width = e.Important ? 8 : 6,
|
||||
Height = e.Important ? 8 : 6,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Background = e.Brush,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Margin = new Thickness(0, e.Important ? 5 : 6, 0, 0)
|
||||
};
|
||||
var text = new TextBlock
|
||||
{
|
||||
Text = e.Text,
|
||||
Foreground = e.Important ? Palette.Text : Palette.DimText,
|
||||
FontSize = 12.5,
|
||||
FontWeight = e.Important ? FontWeight.SemiBold : FontWeight.Normal,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
Grid.SetColumn(dot, 1);
|
||||
Grid.SetColumn(text, 2);
|
||||
grid.Children.Add(time);
|
||||
grid.Children.Add(dot);
|
||||
grid.Children.Add(text);
|
||||
return grid;
|
||||
}
|
||||
|
||||
/// <summary>The events of the user's train, in the order they happened.</summary>
|
||||
public static List<BlackBoxEvent> Collect(Recording recording, IBlackBoxStrings strings)
|
||||
{
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
var events = new List<BlackBoxEvent>();
|
||||
foreach (var c in recording.UserCommands())
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCommand"], c.Name), Palette.Command, false));
|
||||
foreach (var d in recording.DriverChanges)
|
||||
{
|
||||
if (recording.CabAt(d.Time + 0.5) is not { } s || s.Vehicle != d.Vehicle)
|
||||
continue;
|
||||
events.Add(new BlackBoxEvent(d.Time, strings[d.Driving == Driver.Ai ? "EventAutopilotOn" : "EventAutopilotOff"],
|
||||
Palette.Autopilot, true));
|
||||
}
|
||||
foreach (var p in recording.UserPassings())
|
||||
{
|
||||
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
|
||||
events.Add(p.AtDanger
|
||||
? new BlackBoxEvent(p.Time, string.Format(culture, strings["EventDanger"], name, Math.Abs(p.SpeedKmh), p.Overrun), Palette.Over, true)
|
||||
: new BlackBoxEvent(p.Time, string.Format(culture, strings["EventPassed"], name, MapView.AspectText(strings, p.Speed1, p.Speed2),
|
||||
Math.Abs(p.SpeedKmh)), Palette.Of(new AspectChange(p.Time, p.Signal, p.Speed1, p.Speed2, "")), false));
|
||||
}
|
||||
AddUserVehicles(recording, strings, events, culture);
|
||||
AddStations(recording, strings, events, culture);
|
||||
foreach (var horn in recording.UserHornChecks())
|
||||
events.Add(new BlackBoxEvent(horn.Request.Time, strings[horn.Sounded ? "EventHornSounded" : "EventHornMissed"],
|
||||
horn.Sounded ? Palette.HornPoint : Palette.Bad, !horn.Sounded));
|
||||
foreach (var span in recording.SecurityBrakings())
|
||||
{
|
||||
// Which of them was not acknowledged: the lamps blinking when it braked.
|
||||
string who = recording.CabAt(span.Start) is { } s
|
||||
? s.Has(SecurityFlags.CabSignal) && s.Has(SecurityFlags.Vigilance) ? "CA/SHP" : s.Has(SecurityFlags.CabSignal) ? "SHP" : "CA"
|
||||
: "CA";
|
||||
events.Add(new BlackBoxEvent(span.Start, string.Format(culture, strings["EventSecurityBraking"], who), Palette.Bad, true));
|
||||
}
|
||||
AddCameras(recording, strings, events, culture);
|
||||
AddCouplings(recording, strings, events, culture);
|
||||
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<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
UserVehicleChange? before = null;
|
||||
foreach (var u in recording.UserVehicles)
|
||||
{
|
||||
string vehicle = recording.Vehicle(u.Vehicle)?.Name ?? "";
|
||||
// Only the cab changed: told as such.
|
||||
if (before is { } b && b.Vehicle == u.Vehicle && b.InCab == u.InCab && b.CabEnd != u.CabEnd && u.CabEnd != 0)
|
||||
events.Add(new BlackBoxEvent(u.Time, string.Format(culture, strings["EventCab"], strings[u.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], vehicle),
|
||||
Palette.UserTrain, true));
|
||||
else
|
||||
{
|
||||
string text = u.Vehicle == 0
|
||||
? strings["EventNoVehicle"]
|
||||
: string.Format(culture, strings[u.InCab ? "EventInVehicle" : "EventOutside"], vehicle);
|
||||
events.Add(new BlackBoxEvent(u.Time, text, u.InCab ? Palette.UserTrain : Palette.DimText, true));
|
||||
}
|
||||
before = u;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddStations(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
foreach (var st in recording.UserStations())
|
||||
{
|
||||
switch (st.Kind)
|
||||
{
|
||||
case StationKind.Stopped:
|
||||
{
|
||||
float error = st.StopError;
|
||||
string where = Math.Abs(error) < 1
|
||||
? strings["TtStopExact"]
|
||||
: string.Format(culture, strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error));
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStopped"], StationNames.Display(st.Station), where), Palette.Station, true));
|
||||
break;
|
||||
}
|
||||
case StationKind.Passed:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationPassed"], StationNames.Display(st.Station)), Palette.Station, false));
|
||||
break;
|
||||
case StationKind.Missed:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationMissed"], StationNames.Display(st.Station)), Palette.Bad, true));
|
||||
break;
|
||||
case StationKind.Ready:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventReady"], StationNames.Display(st.Station)), Palette.Station, false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The camera of the user going to another mode: the cab, outside, free.</summary>
|
||||
private static void AddCameras(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
CameraMode? mode = null;
|
||||
foreach (var c in recording.Cameras)
|
||||
{
|
||||
if (c.Mode == mode)
|
||||
continue;
|
||||
if (mode is not null)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCamera"], strings[CameraKey(c.Mode)]), Palette.DimText, false));
|
||||
mode = c.Mode;
|
||||
}
|
||||
}
|
||||
|
||||
public static string CameraKey(CameraMode mode) => mode switch
|
||||
{
|
||||
CameraMode.Cab => "CameraCab",
|
||||
CameraMode.External => "CameraExternal",
|
||||
CameraMode.Debug => "CameraDebug",
|
||||
_ => "CameraFree"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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")
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void AddCabChanges(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> 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<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
string Name(int id) => recording.Vehicle(id)?.Name ?? id.ToString(culture);
|
||||
foreach (var c in recording.UserCouplings())
|
||||
{
|
||||
if (c.Coupled)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCoupled"], Name(c.Vehicle), Name(c.Other)), Palette.Good, true));
|
||||
else if (c.Uncoupled)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventUncoupled"], Name(c.Vehicle), Name(c.PreviousOther)), Palette.Warn, true));
|
||||
else
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCouplingChanged"], Name(c.Vehicle), Name(c.Other),
|
||||
CouplingText(strings, c.Flags)), Palette.DimText, false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a coupling joins, in words.</summary>
|
||||
public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var (flag, key) in new[]
|
||||
{
|
||||
(CouplingFlags.Coupler, "CouplingCoupler"), (CouplingFlags.BrakePipe, "CouplingBrakePipe"),
|
||||
(CouplingFlags.MainPipe, "CouplingMainPipe"), (CouplingFlags.Control, "CouplingControl"),
|
||||
(CouplingFlags.Power, "CouplingPower"), (CouplingFlags.Heating, "CouplingHeating"),
|
||||
(CouplingFlags.Gangway, "CouplingGangway")
|
||||
})
|
||||
if ((flags & flag) != 0)
|
||||
parts.Add(strings[key]);
|
||||
return parts.Count == 0 ? strings["CouplingNothing"] : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
766
Eu07.BlackBox.Views/MapView.cs
Normal file
766
Eu07.BlackBox.Views/MapView.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The map of the scenery at a moment of the recording: the rail tracks with the switches
|
||||
/// as they lay, the signals in the colour of what they showed, every vehicle where it was,
|
||||
/// and the way the user's train went, coloured by how its speed stood against the AI's.
|
||||
/// North is up, as on the map in the simulator. Dragged to move, the wheel zooms, a
|
||||
/// double click shows it all.
|
||||
/// </summary>
|
||||
public sealed class MapView : Control
|
||||
{
|
||||
private const double MinScale = 0.002;
|
||||
private const double MaxScale = 40;
|
||||
private const double FollowScale = 1.2;
|
||||
|
||||
private Recording? _recording;
|
||||
private int _builtRevision = -1;
|
||||
private int _builtTracks = -1;
|
||||
private StreamGeometry? _tracks;
|
||||
private readonly List<(int Index, int Branch, StreamGeometry Way)> _switchWays = new();
|
||||
private readonly StreamGeometry?[] _trail = new StreamGeometry?[5];
|
||||
private Rect _world;
|
||||
|
||||
private double _centerX, _centerZ;
|
||||
private double _scale = 0.2; // pixels per metre
|
||||
private bool _fitted;
|
||||
|
||||
private Point? _dragFrom;
|
||||
private (double X, double Z) _dragCenter;
|
||||
private Point? _pointer;
|
||||
private double _time;
|
||||
private bool _follow;
|
||||
|
||||
/// <summary>The user moved the map away from the train: following is off.</summary>
|
||||
public event Action? FollowStopped;
|
||||
|
||||
public MapView()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Focusable = true;
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_builtRevision = -1;
|
||||
_builtTracks = -1;
|
||||
_fitted = false;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keeps the user's train in the middle.</summary>
|
||||
public bool Follow
|
||||
{
|
||||
get => _follow;
|
||||
set
|
||||
{
|
||||
// Close enough to see the train go, unless already closer.
|
||||
if (value && !_follow)
|
||||
_scale = Math.Max(_scale, FollowScale);
|
||||
_follow = value;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
/// <summary>Closer by the factor given, about the middle.</summary>
|
||||
public void ZoomBy(double factor)
|
||||
{
|
||||
_scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
/// <summary>The map as a whole in view.</summary>
|
||||
public void Fit()
|
||||
{
|
||||
FitWorld();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
// Also from the render, which must not ask for another one.
|
||||
private void FitWorld()
|
||||
{
|
||||
if (_world.Width <= 0 && _world.Height <= 0)
|
||||
return;
|
||||
var size = Bounds.Size;
|
||||
if (size.Width <= 0 || size.Height <= 0)
|
||||
return;
|
||||
_centerX = -_world.Center.X;
|
||||
_centerZ = -_world.Center.Y;
|
||||
_scale = Math.Clamp(Math.Min(size.Width / Math.Max(_world.Width, 50), size.Height / Math.Max(_world.Height, 50)) * 0.92,
|
||||
MinScale, MaxScale);
|
||||
if (_follow)
|
||||
_scale = Math.Max(_scale, FollowScale);
|
||||
_fitted = true;
|
||||
}
|
||||
|
||||
// The map's plane as the simulator's map draws it, seen from above: x to the left (the game's x runs the
|
||||
// other way than a map's east), z up the screen.
|
||||
private static Point Plane(double x, double z) => new(-x, -z);
|
||||
|
||||
private Point ToScreen(double x, double z) =>
|
||||
new((_centerX - x) * _scale + Bounds.Width / 2, (_centerZ - z) * _scale + Bounds.Height / 2);
|
||||
|
||||
private (double X, double Z) ToWorld(Point screen) =>
|
||||
(_centerX - (screen.X - Bounds.Width / 2) / _scale, _centerZ - (screen.Y - Bounds.Height / 2) / _scale);
|
||||
|
||||
private void Build(Recording recording)
|
||||
{
|
||||
if (recording.Tracks.Count != _builtTracks)
|
||||
{
|
||||
_builtTracks = recording.Tracks.Count;
|
||||
_switchWays.Clear();
|
||||
var tracks = new StreamGeometry();
|
||||
double left = double.MaxValue, top = double.MaxValue, right = double.MinValue, bottom = double.MinValue;
|
||||
using (var g = tracks.Open())
|
||||
{
|
||||
foreach (var track in recording.Tracks)
|
||||
{
|
||||
if (track.Points.Count < 2)
|
||||
continue;
|
||||
foreach (var p in track.Points)
|
||||
{
|
||||
left = Math.Min(left, -p.X);
|
||||
right = Math.Max(right, -p.X);
|
||||
top = Math.Min(top, -p.Z);
|
||||
bottom = Math.Max(bottom, -p.Z);
|
||||
}
|
||||
if (track.Kind == TrackKind.SwitchBranch)
|
||||
{
|
||||
var way = new StreamGeometry();
|
||||
using (var w = way.Open())
|
||||
Polyline(w, track.Points);
|
||||
_switchWays.Add((track.Index, track.Branch, way));
|
||||
}
|
||||
else
|
||||
Polyline(g, track.Points);
|
||||
}
|
||||
}
|
||||
_tracks = tracks;
|
||||
_world = left <= right ? new Rect(new Point(left, top), new Point(right, bottom)) : default;
|
||||
}
|
||||
BuildTrail(recording);
|
||||
_builtRevision = recording.Revision;
|
||||
}
|
||||
|
||||
private static void Polyline(StreamGeometryContext g, IReadOnlyList<System.Numerics.Vector3> points)
|
||||
{
|
||||
g.BeginFigure(Plane(points[0].X, points[0].Z), false);
|
||||
for (int i = 1; i < points.Count; i++)
|
||||
g.LineTo(Plane(points[i].X, points[i].Z));
|
||||
g.EndFigure(false);
|
||||
}
|
||||
|
||||
/// <summary>The way the user's train went, a geometry for each judgement of its speed.</summary>
|
||||
private void BuildTrail(Recording recording)
|
||||
{
|
||||
var lines = new List<List<Point>>[_trail.Length];
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
lines[i] = new List<List<Point>>();
|
||||
var cab = recording.Cab;
|
||||
int open = -1;
|
||||
for (int i = 1; i < cab.Count; i++)
|
||||
{
|
||||
var before = cab[i - 1];
|
||||
var sample = cab[i];
|
||||
if (sample.Vehicle != before.Vehicle || sample.Time - before.Time > 1.0)
|
||||
{
|
||||
open = -1;
|
||||
continue;
|
||||
}
|
||||
if (before.Position.DistanceTo(sample.Position) < 0.5 && open >= 0)
|
||||
continue;
|
||||
int kind = (int)DrivingSummary.Judge(sample);
|
||||
if (kind != open)
|
||||
{
|
||||
lines[kind].Add(new List<Point> { Plane(before.Position.X, before.Position.Z) });
|
||||
open = kind;
|
||||
}
|
||||
lines[kind][^1].Add(Plane(sample.Position.X, sample.Position.Z));
|
||||
}
|
||||
for (int kind = 0; kind < _trail.Length; kind++)
|
||||
{
|
||||
if (lines[kind].Count == 0)
|
||||
{
|
||||
_trail[kind] = null;
|
||||
continue;
|
||||
}
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in lines[kind].Where(l => l.Count > 1))
|
||||
{
|
||||
g.BeginFigure(line[0], false);
|
||||
foreach (var p in line.Skip(1))
|
||||
g.LineTo(p);
|
||||
g.EndFigure(false);
|
||||
}
|
||||
}
|
||||
_trail[kind] = geometry;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
context.FillRectangle(Palette.MapBackground, new Rect(size));
|
||||
var recording = _recording;
|
||||
if (recording is null)
|
||||
return;
|
||||
if (recording.Revision != _builtRevision)
|
||||
Build(recording);
|
||||
if (!_fitted)
|
||||
FitWorld();
|
||||
if (_follow && recording.CabPositionAt(_time) is { } followed)
|
||||
{
|
||||
_centerX = followed.X;
|
||||
_centerZ = followed.Z;
|
||||
}
|
||||
|
||||
var toScreen = Matrix.CreateTranslation(_centerX, _centerZ) * Matrix.CreateScale(_scale, _scale) *
|
||||
Matrix.CreateTranslation(size.Width / 2, size.Height / 2);
|
||||
double pixel = 1 / _scale;
|
||||
using (context.PushTransform(toScreen))
|
||||
{
|
||||
// The tracks on a dark casing, so they stand out where they cross and run close.
|
||||
var casing = new Pen(Palette.TrackCasing, 5 * pixel, lineCap: PenLineCap.Round);
|
||||
if (_tracks is not null)
|
||||
context.DrawGeometry(null, casing, _tracks);
|
||||
foreach (var (_, _, way) in _switchWays)
|
||||
context.DrawGeometry(null, casing, way);
|
||||
if (_tracks is not null)
|
||||
context.DrawGeometry(null, new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round), _tracks);
|
||||
var off = new Pen(Palette.SwitchOff, 1.7 * pixel, lineCap: PenLineCap.Round);
|
||||
var on = new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round);
|
||||
// The ways not taken under those taken.
|
||||
foreach (var (index, branch, way) in _switchWays)
|
||||
{
|
||||
int state = recording.SwitchAt(index, _time);
|
||||
if (state >= 0 && state != branch)
|
||||
context.DrawGeometry(null, off, way);
|
||||
}
|
||||
foreach (var (index, branch, way) in _switchWays)
|
||||
{
|
||||
int state = recording.SwitchAt(index, _time);
|
||||
if (state < 0 || state == branch)
|
||||
context.DrawGeometry(null, on, way);
|
||||
}
|
||||
// The way the train went, in the colour of the judgement.
|
||||
for (int kind = 0; kind < _trail.Length; kind++)
|
||||
if (_trail[kind] is { } trail)
|
||||
context.DrawGeometry(null, new Pen(Palette.Of((SpeedJudgement)kind), 3.5 * pixel, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), trail);
|
||||
}
|
||||
|
||||
DrawStopPoints(context, recording);
|
||||
DrawHornPoints(context, recording);
|
||||
DrawSignals(context, recording);
|
||||
DrawPassings(context, recording);
|
||||
DrawVehicles(context, recording);
|
||||
DrawUserTrain(context, recording);
|
||||
DrawCamera(context, recording);
|
||||
DrawScale(context, size);
|
||||
DrawLegend(context, size);
|
||||
DrawHover(context, recording);
|
||||
}
|
||||
|
||||
private bool Visible(Point p, double margin = 20) =>
|
||||
p.X >= -margin && p.Y >= -margin && p.X <= Bounds.Width + margin && p.Y <= Bounds.Height + margin;
|
||||
|
||||
private void DrawSignals(DrawingContext context, Recording recording)
|
||||
{
|
||||
double radius = _scale > 0.5 ? 4.5 : _scale > 0.1 ? 3.5 : 2.5;
|
||||
var outline = new Pen(Palette.Background, 1);
|
||||
foreach (var signal in recording.Signals)
|
||||
{
|
||||
var p = ToScreen(signal.Position.X, signal.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
context.DrawEllipse(Palette.Of(recording.AspectAt(signal.Index, _time)), outline, p, radius, radius);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stop points (W4): a white plate, blue where the user's train stopped, red where it
|
||||
/// missed the stop; the name of the station once close enough to read it.
|
||||
/// </summary>
|
||||
// The way the trains a stop point is for go: a wedge ahead of the plate, as a train sees the W4 coming.
|
||||
private static void DrawStopWay(DrawingContext context, Point plate, float heading, double size, IBrush fill, IPen outline)
|
||||
{
|
||||
// the map shows x to the left, z up: a heading from +z towards +x turns up the screen and to the left
|
||||
var ahead = new Vector(-Math.Sin(heading), -Math.Cos(heading));
|
||||
var across = new Vector(-ahead.Y, ahead.X);
|
||||
var tip = plate + ahead * (size * 4.2);
|
||||
var root = plate + ahead * (size * 1.8);
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(tip, true);
|
||||
g.LineTo(root + across * (size * 1.3));
|
||||
g.LineTo(root - across * (size * 1.3));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(fill, outline, geometry);
|
||||
}
|
||||
|
||||
private void DrawStopPoints(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.StopPoints.Count == 0)
|
||||
return;
|
||||
var outcomes = new Dictionary<(double, double), StationKind>();
|
||||
foreach (var station in recording.UserStations())
|
||||
{
|
||||
if (station.Time > _time || station.Kind is not (StationKind.Stopped or StationKind.Missed))
|
||||
continue;
|
||||
var key = (Math.Round(station.Position.X, 1), Math.Round(station.Position.Z, 1));
|
||||
if (!outcomes.TryGetValue(key, out var was) || was != StationKind.Missed)
|
||||
outcomes[key] = station.Kind;
|
||||
}
|
||||
// Close up every plate is named; from afar each station once, where the names don't cover each other.
|
||||
bool near = _scale > 0.35;
|
||||
double size = _scale > 0.5 ? 4 : 3;
|
||||
var outline = new Pen(Palette.MapBackground, 1);
|
||||
var labels = new List<(Point At, string Name, IBrush Fill)>();
|
||||
foreach (var point in recording.StopPoints)
|
||||
{
|
||||
var p = ToScreen(point.Position.X, point.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
var fill = outcomes.TryGetValue((Math.Round(point.Position.X, 1), Math.Round(point.Position.Z, 1)), out var kind)
|
||||
? kind == StationKind.Missed ? Palette.Bad : Palette.Station
|
||||
: Palette.StopPoint;
|
||||
var plate = new Rect(p.X - size, p.Y - size * 1.3, size * 2, size * 2.6);
|
||||
// a W32 hollow, in the colour a W4 would be filled with
|
||||
if (point.ForShortTrains)
|
||||
context.DrawRectangle(Palette.MapBackground, new Pen(fill, 1.5), plate, 1.5, 1.5);
|
||||
else
|
||||
context.DrawRectangle(fill, outline, plate, 1.5, 1.5);
|
||||
if (point.HasHeading)
|
||||
DrawStopWay(context, p, point.Heading, size, fill, outline);
|
||||
labels.Add((p, StationNames.Display(point.Station), fill));
|
||||
}
|
||||
var delays = StationProgress.StationDelays(recording, _time);
|
||||
var named = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var taken = new List<Rect>();
|
||||
// The label of the user's train stays clear.
|
||||
if (recording.CabPositionAt(_time) is { } train)
|
||||
{
|
||||
var mark = ToScreen(train.X, train.Z);
|
||||
taken.Add(new Rect(mark.X - 10, mark.Y - 24, 170, 34));
|
||||
}
|
||||
var culture = System.Globalization.CultureInfo.CurrentCulture;
|
||||
foreach (var (p, name, fill) in labels)
|
||||
{
|
||||
if (!near && !named.Add(name))
|
||||
continue;
|
||||
var text = Text(name, Palette.Text, near ? 11 : 10);
|
||||
FormattedText? late = delays.TryGetValue(name, out var minutes)
|
||||
? Text(string.Format(culture, Strings["Minutes"], minutes), Palette.Delay(minutes), near ? 11 : 10)
|
||||
: null;
|
||||
double width = text.Width + (late is null ? 0 : late.Width + 6) + 8;
|
||||
var box = new Rect(p.X + size + 3, p.Y - text.Height / 2 - 1, width, text.Height + 2);
|
||||
if (taken.Exists(r => r.Inflate(2).Intersects(box)))
|
||||
continue;
|
||||
taken.Add(box);
|
||||
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.8), null, box);
|
||||
context.DrawText(text, new Point(box.X + 4, box.Y + 1));
|
||||
if (late is not null)
|
||||
context.DrawText(late, new Point(box.X + 4 + text.Width + 6, box.Y + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The horn points (W6): a diamond, green where the user sounded the horn, red where not.</summary>
|
||||
private void DrawHornPoints(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.HornPoints.Count == 0)
|
||||
return;
|
||||
var checks = recording.UserHornChecks().Where(c => c.Request.Time <= _time).ToList();
|
||||
double size = _scale > 0.5 ? 5 : 3.5;
|
||||
var outline = new Pen(Palette.MapBackground, 1);
|
||||
foreach (var point in recording.HornPoints)
|
||||
{
|
||||
var p = ToScreen(point.Position.X, point.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
var check = checks.LastOrDefault(c => c.Request.Position.DistanceTo(point.Position) < 1);
|
||||
var fill = check is null ? Palette.HornPoint : check.Sounded ? Palette.Good : Palette.Bad;
|
||||
context.DrawGeometry(fill, outline, Diamond(p, size));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from, when not from the cab: a dot with a wedge of where it looked.</summary>
|
||||
private void DrawCamera(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.CameraAt(_time) is not { } camera || camera.Mode == CameraMode.Cab)
|
||||
return;
|
||||
var p = ToScreen(camera.Position.X, camera.Position.Z);
|
||||
if (!Visible(p, 60))
|
||||
return;
|
||||
// The heading from +z towards +x: up the screen and to the left.
|
||||
const double reach = 46, half = 0.45;
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(p, true);
|
||||
g.LineTo(p + new Vector(-Math.Sin(camera.Heading - half), -Math.Cos(camera.Heading - half)) * reach);
|
||||
g.ArcTo(p + new Vector(-Math.Sin(camera.Heading + half), -Math.Cos(camera.Heading + half)) * reach, new Size(reach, reach), 0,
|
||||
false, SweepDirection.CounterClockwise);
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(Palette.Faded(Palette.HornPoint, 0.18), new Pen(Palette.Faded(Palette.HornPoint, 0.5), 1), geometry);
|
||||
context.DrawEllipse(Palette.HornPoint, new Pen(Palette.MapBackground, 1.5), p, 4.5, 4.5);
|
||||
}
|
||||
|
||||
/// <summary>Where the user's train passed signals; those at danger ringed red.</summary>
|
||||
private void DrawPassings(DrawingContext context, Recording recording)
|
||||
{
|
||||
var passed = new Pen(Palette.Within, 1.5);
|
||||
var danger = new Pen(Palette.Over, 2.5);
|
||||
foreach (var passing in recording.UserPassings())
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The head of the user's train between its samples, ten a second, on top of everything.</summary>
|
||||
private void DrawUserTrain(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.CabPositionAt(_time) is not { } position)
|
||||
return;
|
||||
var p = ToScreen(position.X, position.Z);
|
||||
context.DrawEllipse(Palette.MapBackground, new Pen(Palette.UserTrain, 2), p, 7, 7);
|
||||
context.DrawEllipse(Palette.UserTrain, null, p, 3, 3);
|
||||
// The train and how late it is, by its mark.
|
||||
if (StationProgress.DelayAt(recording, _time) is not { } delay)
|
||||
return;
|
||||
var culture = System.Globalization.CultureInfo.CurrentCulture;
|
||||
var train = Text(delay.Train, Palette.UserTrain, 11, FontWeight.SemiBold);
|
||||
var late = Text(Math.Abs(delay.Minutes) < 0.5 ? Strings["OnTime"] : string.Format(culture, Strings["Minutes"], delay.Minutes),
|
||||
Palette.Delay(delay.Minutes), 11, FontWeight.SemiBold);
|
||||
var box = TrainLabel(p, train, late);
|
||||
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.85), null, box);
|
||||
context.DrawText(train, new Point(box.X + 4, box.Y + 2));
|
||||
context.DrawText(late, new Point(box.X + train.Width + 12, box.Y + 2));
|
||||
}
|
||||
|
||||
/// <summary>Where the label of the user's train goes, by its mark.</summary>
|
||||
private static Rect TrainLabel(Point mark, FormattedText train, FormattedText late) =>
|
||||
new(mark.X + 12, mark.Y - 22, train.Width + late.Width + 16, train.Height + 4);
|
||||
|
||||
private void DrawScale(DrawingContext context, Size size)
|
||||
{
|
||||
double metres = Nice(120 / _scale);
|
||||
double length = metres * _scale;
|
||||
var y = size.Height - 14;
|
||||
var pen = new Pen(Palette.DimText, 1.5);
|
||||
context.DrawLine(pen, new Point(size.Width - 16 - length, y), new Point(size.Width - 16, y));
|
||||
context.DrawLine(pen, new Point(size.Width - 16 - length, y - 4), new Point(size.Width - 16 - length, y + 1));
|
||||
context.DrawLine(pen, new Point(size.Width - 16, y - 4), new Point(size.Width - 16, y + 1));
|
||||
var text = Text(metres >= 1000 ? $"{metres / 1000:0.#} km" : $"{metres:0} m", Palette.DimText, 11);
|
||||
context.DrawText(text, new Point(size.Width - 16 - length / 2 - text.Width / 2, y - 18));
|
||||
}
|
||||
|
||||
private void DrawLegend(DrawingContext context, Size size)
|
||||
{
|
||||
var rows = new (IBrush Brush, string Text)[]
|
||||
{
|
||||
(Palette.Within, Strings["LegendWithin"]),
|
||||
(Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)),
|
||||
(Palette.Over, 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The name of the signal or the vehicle under the pointer.</summary>
|
||||
private void DrawHover(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (_pointer is not { } pointer || _dragFrom is not null)
|
||||
return;
|
||||
string? label = null;
|
||||
double best = 10;
|
||||
foreach (var signal in recording.Signals)
|
||||
{
|
||||
double d = Distance(ToScreen(signal.Position.X, signal.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
var aspect = recording.AspectAt(signal.Index, _time);
|
||||
label = aspect is { } a ? $"{signal.Name} · {AspectText(Strings, a.Speed1, a.Speed2)}" : signal.Name;
|
||||
}
|
||||
}
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
foreach (var point in recording.StopPoints)
|
||||
{
|
||||
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
label = string.Format(culture, Strings[point.ForShortTrains ? "ShortStopHover" : "StopPointHover"], StationNames.Display(point.Station));
|
||||
if (point.Platform > 0)
|
||||
label += " · " + string.Format(culture, Strings["PlatformHover"], point.PlatformLength);
|
||||
else if (point.Platform < 0)
|
||||
label += " · " + string.Format(culture, Strings["ShortTrainsHover"], point.PlatformLength);
|
||||
// the preferred place to stop, as the driver reads it: the head, or the middle of the train
|
||||
if (point.StopAt < 0)
|
||||
label += " · " + string.Format(culture, Strings["StopHeadHover"], -point.StopAt);
|
||||
else if (point.StopAt > 0)
|
||||
label += " · " + string.Format(culture, Strings["StopMiddleHover"], point.StopAt);
|
||||
}
|
||||
}
|
||||
foreach (var point in recording.HornPoints)
|
||||
{
|
||||
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
label = string.Format(culture, Strings["HornHover"], point.Name, point.Duration);
|
||||
}
|
||||
}
|
||||
foreach (var state in recording.VehiclesAt(_time))
|
||||
{
|
||||
double d = Distance(ToScreen(state.Position.X, state.Position.Z), pointer);
|
||||
if (d < best && recording.Vehicle(state.Id) is { } info)
|
||||
{
|
||||
best = d;
|
||||
label = string.IsNullOrEmpty(info.Train)
|
||||
? $"{info.Name} · {Math.Abs(state.SpeedKmh):0} km/h"
|
||||
: $"{info.Name} ({info.Train}) · {Math.Abs(state.SpeedKmh):0} km/h";
|
||||
}
|
||||
}
|
||||
if (label is null)
|
||||
return;
|
||||
var text = Text(label, Palette.Text, 12);
|
||||
var box = new Rect(pointer.X + 14, pointer.Y - 12 - text.Height, text.Width + 16, text.Height + 8);
|
||||
context.DrawRectangle(Palette.Faded(Palette.Card, 0.95), new Pen(Palette.CardBorder, 1), box, 6, 6);
|
||||
context.DrawText(text, new Point(box.Left + 8, box.Top + 4));
|
||||
}
|
||||
|
||||
/// <summary>An aspect as the speeds it gives: stop, a speed, or go.</summary>
|
||||
public static string AspectText(IBlackBoxStrings strings, float speed1, float speed2)
|
||||
{
|
||||
if (speed1 == 0)
|
||||
return strings["AspectStop"];
|
||||
if (speed1 < 0 && speed2 < 0)
|
||||
return strings["AspectFree"];
|
||||
string first = speed1 < 0 ? "∞" : speed1.ToString("0", CultureInfo.InvariantCulture);
|
||||
string second = speed2 < 0 ? "∞" : speed2.ToString("0", CultureInfo.InvariantCulture);
|
||||
return $"{first}/{second}";
|
||||
}
|
||||
|
||||
private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
|
||||
|
||||
private static double Nice(double value)
|
||||
{
|
||||
double power = Math.Pow(10, Math.Floor(Math.Log10(value)));
|
||||
double unit = value / power;
|
||||
return (unit < 2 ? 1 : unit < 5 ? 2 : 5) * power;
|
||||
}
|
||||
|
||||
internal static FormattedText Text(string text, IBrush brush, double size, FontWeight weight = FontWeight.Normal) =>
|
||||
new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
weight == FontWeight.Normal ? Typeface.Default : new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, weight),
|
||||
size, brush);
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
var point = e.GetCurrentPoint(this);
|
||||
if (!point.Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
if (e.ClickCount == 2)
|
||||
{
|
||||
if (_follow)
|
||||
StopFollowing();
|
||||
Fit();
|
||||
return;
|
||||
}
|
||||
_dragFrom = point.Position;
|
||||
_dragCenter = (_centerX, _centerZ);
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
var position = e.GetPosition(this);
|
||||
_pointer = position;
|
||||
if (_dragFrom is { } from)
|
||||
{
|
||||
var moved = position - from;
|
||||
if (_follow && (Math.Abs(moved.X) > 3 || Math.Abs(moved.Y) > 3))
|
||||
StopFollowing();
|
||||
if (!_follow)
|
||||
{
|
||||
_centerX = _dragCenter.X + moved.X / _scale;
|
||||
_centerZ = _dragCenter.Z + moved.Y / _scale;
|
||||
}
|
||||
}
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
_dragFrom = null;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
_pointer = null;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||
{
|
||||
base.OnPointerWheelChanged(e);
|
||||
var at = e.GetPosition(this);
|
||||
var (x, z) = ToWorld(at);
|
||||
_scale = Math.Clamp(_scale * Math.Pow(1.25, e.Delta.Y), MinScale, MaxScale);
|
||||
if (!_follow)
|
||||
{
|
||||
// The place under the pointer stays under it.
|
||||
_centerX = x + (at.X - Bounds.Width / 2) / _scale;
|
||||
_centerZ = z + (at.Y - Bounds.Height / 2) / _scale;
|
||||
}
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void StopFollowing()
|
||||
{
|
||||
_follow = false;
|
||||
FollowStopped?.Invoke();
|
||||
}
|
||||
}
|
||||
113
Eu07.BlackBox.Views/Palette.cs
Normal file
113
Eu07.BlackBox.Views/Palette.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The colours the map, the timeline and the panels share: those of the starter's Carbon
|
||||
/// look for the window, its green for what is picked, and colours of their own for the data.
|
||||
/// </summary>
|
||||
public static class Palette
|
||||
{
|
||||
// The window, the cards on it and the lines between.
|
||||
public static readonly IBrush Background = Brush("#15171B");
|
||||
public static readonly IBrush Panel = Brush("#1E2327");
|
||||
public static readonly IBrush Card = Brush("#1E2327");
|
||||
public static readonly IBrush CardHover = Brush("#2A3036");
|
||||
public static readonly IBrush CardBorder = Brush("#3A424A");
|
||||
public static readonly IBrush Grid = Brush("#2A3036");
|
||||
public static readonly IBrush MapBackground = Brush("#121417");
|
||||
public static readonly IBrush Text = Brush("#E6E8EA");
|
||||
public static readonly IBrush DimText = Brush("#9098A0");
|
||||
public static readonly IBrush FaintText = Brush("#646C74");
|
||||
/// <summary>The headers of the panels, as the group boxes of the starter.</summary>
|
||||
public static readonly IBrush Header = Brush("#2A3036");
|
||||
/// <summary>The green of what is picked or on: bright for lines and text, deep for fills.</summary>
|
||||
public static readonly IBrush Accent = Brush("#41C400");
|
||||
public static readonly IBrush AccentFill = Brush("#177F00");
|
||||
public static readonly IBrush AccentFillHover = Brush("#1E9000");
|
||||
public static readonly IBrush AccentText = Brush("#FFFFFF");
|
||||
public static readonly IBrush Good = Brush("#4cc46e");
|
||||
|
||||
/// <summary>A delay: green on time, yellow a little late, red late, blue early.</summary>
|
||||
public static IBrush Delay(double? minutes) => minutes switch
|
||||
{
|
||||
null => Good,
|
||||
> 3 => Bad,
|
||||
> 0.5 => Warn,
|
||||
< -0.5 => Station,
|
||||
_ => Good
|
||||
};
|
||||
|
||||
public static readonly IBrush Warn = Brush("#e8c547");
|
||||
public static readonly IBrush Bad = Brush("#f0584a");
|
||||
|
||||
public static readonly IBrush Track = Brush("#6f7b88");
|
||||
public static readonly IBrush TrackCasing = Brush("#1d232b");
|
||||
public static readonly IBrush SwitchOff = Brush("#323a44");
|
||||
public static readonly IBrush Vehicle = Brush("#c9a26b");
|
||||
public static readonly IBrush VehicleAi = Brush("#9fb0c3");
|
||||
public static readonly IBrush UserTrain = Brush("#ff9d1c");
|
||||
public static readonly IBrush Cursor = Brush("#ffb000");
|
||||
|
||||
public static readonly IBrush Within = Brush("#4cc46e");
|
||||
public static readonly IBrush Slightly = Brush("#e8c547");
|
||||
public static readonly IBrush Over = Brush("#f0584a");
|
||||
public static readonly IBrush Autopilot = Brush("#4a90e2");
|
||||
public static readonly IBrush NoData = Brush("#5f6871");
|
||||
|
||||
public static readonly IBrush Speed = Brush("#f2f4f6");
|
||||
public static readonly IBrush SpeedFill = new SolidColorBrush(Color.Parse("#f2f4f6"), 0.06).ToImmutable();
|
||||
public static readonly IBrush Target = Brush("#4cc46e");
|
||||
public static readonly IBrush Limit = Brush("#ff8c2a");
|
||||
|
||||
public static readonly IBrush SignalStop = Brush("#ff3b30");
|
||||
public static readonly IBrush SignalLimited = Brush("#ffcc00");
|
||||
public static readonly IBrush SignalFree = Brush("#34c759");
|
||||
public static readonly IBrush SignalShunt = Brush("#e8f0ff");
|
||||
public static readonly IBrush SignalShuntStop = Brush("#4a6cff");
|
||||
public static readonly IBrush SignalUnknown = Brush("#808890");
|
||||
|
||||
public static readonly IBrush StopPoint = Brush("#f4f6f8");
|
||||
public static readonly IBrush Station = Brush("#5aa9ff");
|
||||
public static readonly IBrush HornPoint = Brush("#b9a6ff");
|
||||
|
||||
public static readonly IBrush Brakes = Brush("#e05a52");
|
||||
public static readonly IBrush Controller = Brush("#2bb3a3");
|
||||
public static readonly IBrush Vigilance = Brush("#e8c547");
|
||||
public static readonly IBrush CabSignal = Brush("#f08a3a");
|
||||
public static readonly IBrush LampOff = Brush("#252c35");
|
||||
public static readonly IBrush LampOn = Brush("#4cc46e");
|
||||
public static readonly IBrush LampWarning = Brush("#e8c547");
|
||||
public static readonly IBrush LampAlarm = Brush("#f0584a");
|
||||
public static readonly IBrush LightWhite = Brush("#fff6d8");
|
||||
public static readonly IBrush LightRed = Brush("#ff3b30");
|
||||
public static readonly IBrush Command = Brush("#8b96a3");
|
||||
|
||||
public static IBrush Of(SpeedJudgement judgement) => judgement switch
|
||||
{
|
||||
SpeedJudgement.Within => Within,
|
||||
SpeedJudgement.Slightly => Slightly,
|
||||
SpeedJudgement.Over => Over,
|
||||
SpeedJudgement.Autopilot => Autopilot,
|
||||
_ => NoData
|
||||
};
|
||||
|
||||
/// <summary>What a signal shows: the colour of its main light, as the in-game map draws it.</summary>
|
||||
public static IBrush Of(AspectChange? aspect)
|
||||
{
|
||||
if (aspect is not { } a)
|
||||
return SignalUnknown;
|
||||
bool shunt = a.Command.StartsWith("Shunt", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (a.Speed1 == 0)
|
||||
return shunt ? SignalShuntStop : SignalStop;
|
||||
if (shunt)
|
||||
return SignalShunt;
|
||||
return a.Speed1 < 0 && a.Speed2 != 0 ? SignalFree : SignalLimited;
|
||||
}
|
||||
|
||||
/// <summary>A colour seen through: for fills under lines and glows.</summary>
|
||||
public static IBrush Faded(IBrush brush, double opacity) =>
|
||||
new SolidColorBrush(((ISolidColorBrush)brush).Color, opacity).ToImmutable();
|
||||
|
||||
private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)).ToImmutable();
|
||||
}
|
||||
704
Eu07.BlackBox.Views/TimelineView.cs
Normal file
704
Eu07.BlackBox.Views/TimelineView.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The user's train over the time of the recording, by the clock of the simulation: its
|
||||
/// speed, the speed the AI would hold and the next limit it saw, below them bands of who
|
||||
/// drove and how against the AI, of the controller, the brakes, the alerter (CA), the cab
|
||||
/// signalling (SHP) and the camera, and marks of the commands, the signals passed, the
|
||||
/// stations, the horn points and the couplings. A click goes to its moment; the wheel zooms
|
||||
/// the time, dragging moves it.
|
||||
/// </summary>
|
||||
public sealed class TimelineView : Control
|
||||
{
|
||||
private const double Left = 56;
|
||||
private const double Right = 12;
|
||||
private const double Top = 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;
|
||||
|
||||
/// <summary>A moment was clicked.</summary>
|
||||
public event Action<double>? Seek;
|
||||
|
||||
/// <summary>The moment under the pointer, null once it leaves.</summary>
|
||||
public event Action<double?>? Hovered;
|
||||
|
||||
public TimelineView()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
MinHeight = 160;
|
||||
}
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_secondsPerPixel = -1;
|
||||
_start = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
// Kept in view while it plays.
|
||||
if (_secondsPerPixel > 0 && _recording is not null)
|
||||
{
|
||||
double end = _start + PlotWidth * _secondsPerPixel;
|
||||
if (value > end || value < _start)
|
||||
_start = Math.Max(0, value - PlotWidth * _secondsPerPixel * 0.1);
|
||||
}
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All of the recording in view.</summary>
|
||||
public void Fit()
|
||||
{
|
||||
_secondsPerPixel = -1;
|
||||
_start = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private double PlotWidth => Math.Max(10, Bounds.Width - Left - Right);
|
||||
private double ChartBottom => Bounds.Height - AxisHeight - MarksHeight - Bands * (BandHeight + BandGap) - 6;
|
||||
private double X(double t) => Left + (t - _start) / _secondsPerPixel;
|
||||
private double TimeAt(double x) => _start + (x - Left) * _secondsPerPixel;
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
var recording = _recording;
|
||||
if (recording is null || recording.Duration <= 0)
|
||||
return;
|
||||
if (_secondsPerPixel <= 0)
|
||||
_secondsPerPixel = Math.Max(recording.Duration, 60) / PlotWidth;
|
||||
|
||||
double end = _start + PlotWidth * _secondsPerPixel;
|
||||
double chartBottom = ChartBottom;
|
||||
Measure(recording);
|
||||
double top = _speedTop;
|
||||
double Y(double kmh) => chartBottom - Math.Clamp(kmh / top, 0, 1.05) * (chartBottom - Top);
|
||||
|
||||
using (context.PushClip(new Rect(Left, 0, PlotWidth, size.Height)))
|
||||
{
|
||||
DrawGrid(context, top, Y, size);
|
||||
DrawCurves(context, recording, Y, end);
|
||||
double bands = chartBottom + 6;
|
||||
DrawBands(context, recording, bands, end);
|
||||
DrawMarks(context, recording, bands + Bands * (BandHeight + BandGap), end);
|
||||
DrawAxis(context, recording, size.Height - AxisHeight, end);
|
||||
if (_hover is { } hover)
|
||||
context.DrawLine(new Pen(Palette.DimText, 1, new DashStyle(new double[] { 2, 3 }, 0)),
|
||||
new Point(X(hover), Top), new Point(X(hover), size.Height - AxisHeight));
|
||||
double cursor = X(_time);
|
||||
context.DrawLine(new Pen(Palette.Cursor, 1.5), new Point(cursor, Top - 6), new Point(cursor, size.Height - AxisHeight));
|
||||
// The moment played, on the axis.
|
||||
var clock = MapView.Text(recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), Palette.Cursor, 10, FontWeight.SemiBold);
|
||||
var bubble = new Rect(Math.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);
|
||||
}
|
||||
|
||||
/// <summary>The top of the speed scale and the highest step of the controller, once a reading.</summary>
|
||||
private void Measure(Recording recording)
|
||||
{
|
||||
if (_measured == recording.Revision && ReferenceEquals(_measuredOf, recording))
|
||||
return;
|
||||
float max = 40;
|
||||
int controller = 1;
|
||||
foreach (var s in recording.Cab)
|
||||
{
|
||||
max = Math.Max(max, s.AbsSpeedKmh);
|
||||
if (s.AiValid)
|
||||
max = Math.Max(max, s.Ai.TargetKmh);
|
||||
controller = Math.Max(controller, (int)s.MainController);
|
||||
}
|
||||
_speedTop = Math.Ceiling(max * 1.1 / 20) * 20;
|
||||
_controllerTop = controller;
|
||||
_measured = recording.Revision;
|
||||
_measuredOf = recording;
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext context, double top, Func<double, double> y, Size size)
|
||||
{
|
||||
var pen = new Pen(Palette.Grid, 1);
|
||||
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
|
||||
context.DrawLine(pen, new Point(Left, y(v)), new Point(size.Width - Right, y(v)));
|
||||
}
|
||||
|
||||
private void DrawSpeedLabels(DrawingContext context, double top, Func<double, double> y)
|
||||
{
|
||||
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
|
||||
{
|
||||
var text = MapView.Text(v.ToString("0", CultureInfo.InvariantCulture), Palette.DimText, 10);
|
||||
context.DrawText(text, new Point(Left - 6 - text.Width, y(v) - text.Height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each curve by columns of pixels: in a column the lowest and the highest speed,
|
||||
/// so an hour draws as fast as a minute.
|
||||
/// </summary>
|
||||
private void DrawCurves(DrawingContext context, Recording recording, Func<double, double> y, double end)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
int first = Math.Max(0, recording.CabIndexAt(_start));
|
||||
var speed = new Curve();
|
||||
var target = new Curve();
|
||||
var limits = new List<Point>();
|
||||
int column = int.MinValue;
|
||||
CabSample? before = null;
|
||||
for (int i = first; i < cab.Count; i++)
|
||||
{
|
||||
var s = cab[i];
|
||||
if (s.Time > end + _secondsPerPixel * 2)
|
||||
break;
|
||||
bool gap = before is { } b && (s.Time - b.Time > 1.0 || s.Vehicle != b.Vehicle);
|
||||
if (gap)
|
||||
{
|
||||
speed.Break();
|
||||
target.Break();
|
||||
}
|
||||
int c = (int)Math.Round(X(s.Time));
|
||||
speed.Add(c, y(s.AbsSpeedKmh));
|
||||
// Under the autopilot too: what it aimed at.
|
||||
if (s.AiValid)
|
||||
target.Add(c, y(s.Ai.TargetKmh));
|
||||
else
|
||||
target.Break();
|
||||
if (c != column && s.AiValid && s.Ai.HasNextLimit)
|
||||
limits.Add(new Point(c, y(s.Ai.NextLimitKmh)));
|
||||
column = c;
|
||||
before = s;
|
||||
}
|
||||
if (speed.Fill(y(0)) is { } fill)
|
||||
context.DrawGeometry(Palette.SpeedFill, null, fill);
|
||||
if (target.Geometry() is { } t)
|
||||
context.DrawGeometry(null, new Pen(Palette.Target, 1.5), t);
|
||||
var limitPen = new Pen(Palette.Limit, 2);
|
||||
foreach (var p in limits)
|
||||
context.DrawLine(limitPen, p, p + new Vector(1, 0));
|
||||
if (speed.Geometry() is { } v)
|
||||
context.DrawGeometry(null, new Pen(Palette.Speed, 1.5), v);
|
||||
}
|
||||
|
||||
private void DrawBands(DrawingContext context, Recording recording, double top, double end)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
int first = Math.Max(0, recording.CabIndexAt(_start));
|
||||
var cameras = recording.Cameras;
|
||||
int camera = -1;
|
||||
var rows = new BandRun[Bands];
|
||||
for (int row = 0; row < rows.Length; row++)
|
||||
rows[row] = new BandRun(context, top + row * (BandHeight + BandGap));
|
||||
for (int i = first; i < cab.Count; i++)
|
||||
{
|
||||
var s = cab[i];
|
||||
if (s.Time > end)
|
||||
break;
|
||||
double next = i + 1 < cab.Count && cab[i + 1].Time - s.Time <= 1.0 ? cab[i + 1].Time : s.Time + 0.1;
|
||||
double x0 = X(s.Time), x1 = Math.Max(X(next), x0 + 0.6);
|
||||
rows[0].Add(x0, x1, Palette.Of(DrivingSummary.Judge(s)), BandHeight);
|
||||
// The controller as high as its step stands against the highest one used.
|
||||
rows[1].Add(x0, x1, s.MainController > 0 ? Palette.Controller : null,
|
||||
Math.Max(1.5, BandHeight * s.MainController / _controllerTop));
|
||||
rows[2].Add(x0, x1, s.BrakeCylinder > 0.2f ? BrakeShade(s.BrakeCylinder) : null, BandHeight);
|
||||
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
|
||||
{
|
||||
// The alerter and the cab signalling apart, red when not acknowledged in time.
|
||||
rows[3].Add(x0, x1, s.Has(SecurityFlags.VigilanceBeeping) ? Palette.LampAlarm
|
||||
: s.Has(SecurityFlags.Vigilance) ? Palette.Vigilance : null, BandHeight);
|
||||
rows[4].Add(x0, x1, s.Has(SecurityFlags.CabSignalBeeping) ? Palette.LampAlarm
|
||||
: s.Has(SecurityFlags.CabSignal) ? Palette.CabSignal : null, BandHeight);
|
||||
if (s.Has(SecurityFlags.Braking))
|
||||
rows[s.Has(SecurityFlags.CabSignal) && !s.Has(SecurityFlags.Vigilance) ? 4 : 3].Add(x0, x1, Palette.Bad, BandHeight);
|
||||
}
|
||||
else
|
||||
rows[3].Add(x0, x1, (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
|
||||
: (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.Vigilance : null, BandHeight);
|
||||
// Where the user looked from: the samples go on in time, and so does the camera.
|
||||
while (camera + 1 < cameras.Count && cameras[camera + 1].Time <= s.Time)
|
||||
camera++;
|
||||
rows[5].Add(x0, x1, camera < 0 ? null : CameraBrush(cameras[camera].Mode), BandHeight);
|
||||
}
|
||||
foreach (var row in rows)
|
||||
row.Flush();
|
||||
}
|
||||
|
||||
private static readonly IBrush CameraCab = Palette.Faded(Palette.UserTrain, 0.45);
|
||||
private static readonly IBrush CameraOutside = Palette.Faded(Palette.HornPoint, 0.75);
|
||||
private static readonly IBrush CameraFree = Palette.Faded(Palette.Station, 0.75);
|
||||
|
||||
private static IBrush CameraBrush(CameraMode mode) => mode switch
|
||||
{
|
||||
CameraMode.Cab => CameraCab,
|
||||
CameraMode.External => CameraOutside,
|
||||
_ => CameraFree
|
||||
};
|
||||
|
||||
private static readonly IBrush[] BrakeShades = Shades(Palette.Brakes, 8);
|
||||
|
||||
private static IBrush[] Shades(IBrush brush, int count)
|
||||
{
|
||||
var colour = ((ISolidColorBrush)brush).Color;
|
||||
var shades = new IBrush[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
shades[i] = new SolidColorBrush(colour, 0.35 + 0.65 * i / (count - 1));
|
||||
return shades;
|
||||
}
|
||||
|
||||
/// <summary>The harder the brakes hold, the deeper the red.</summary>
|
||||
private static IBrush BrakeShade(float cylinderBar) =>
|
||||
BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
|
||||
|
||||
/// <summary>
|
||||
/// The samples of a band drawn as few rectangles as the band changes: ten a second, each
|
||||
/// narrower than a pixel, they would leave seams between them.
|
||||
/// </summary>
|
||||
private sealed class BandRun(DrawingContext context, double top)
|
||||
{
|
||||
private IBrush? _brush;
|
||||
private double _height, _from, _to;
|
||||
|
||||
public void Add(double x0, double x1, IBrush? brush, double height)
|
||||
{
|
||||
if (brush is not null && ReferenceEquals(brush, _brush) && height == _height && x0 <= _to + 0.5)
|
||||
{
|
||||
_to = Math.Max(_to, x1);
|
||||
return;
|
||||
}
|
||||
Flush();
|
||||
_brush = brush;
|
||||
_height = height;
|
||||
_from = x0;
|
||||
_to = x1;
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
if (_brush is not null)
|
||||
context.FillRectangle(_brush, new Rect(_from, top + BandHeight - _height, _to - _from, _height));
|
||||
_brush = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBandLabels(DrawingContext context, double top)
|
||||
{
|
||||
string[] names = { Strings["BandDriving"], Strings["BandController"], Strings["BandBrakes"], Strings["BandVigilance"],
|
||||
Strings["BandCabSignal"], Strings["BandCamera"] };
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
double y = top + i * (BandHeight + BandGap);
|
||||
context.DrawRectangle(Palette.Faded(Palette.Grid, 0.5), null, new Rect(Left, y, Bounds.Width - Left - Right, BandHeight));
|
||||
var text = MapView.Text(names[i], Palette.FaintText, 9);
|
||||
context.DrawText(text, new Point(Left - 6 - text.Width, y + (BandHeight - text.Height) / 2));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The commands as short grey ticks, the autopilot switched in blue, the signals passed in
|
||||
/// green, at danger tall and red; the stations as blue plates, the horn points as diamonds
|
||||
/// green or red as the horn sounded, the couplings as yellow ticks.
|
||||
/// </summary>
|
||||
private void DrawMarks(DrawingContext context, Recording recording, double top, double end)
|
||||
{
|
||||
foreach (var station in recording.UserStations())
|
||||
{
|
||||
if (station.Time < _start || station.Time > end || station.Kind == StationKind.Ready)
|
||||
continue;
|
||||
double x = X(station.Time);
|
||||
var brush = station.Kind == StationKind.Missed ? Palette.Bad : Palette.Station;
|
||||
context.DrawLine(new Pen(Palette.Faded(brush, 0.35), 1), new Point(x, Top), new Point(x, top));
|
||||
context.DrawRectangle(brush, null, new Rect(x - 3, Top - 13, 6, 8), 1.5, 1.5);
|
||||
}
|
||||
foreach (var horn in recording.UserHornChecks())
|
||||
{
|
||||
if (horn.Request.Time < _start || horn.Request.Time > end)
|
||||
continue;
|
||||
double x = X(horn.Request.Time);
|
||||
var brush = horn.Sounded ? Palette.Good : Palette.Bad;
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(new Point(x, Top - 14), true);
|
||||
g.LineTo(new Point(x + 4, Top - 10));
|
||||
g.LineTo(new Point(x, Top - 6));
|
||||
g.LineTo(new Point(x - 4, Top - 10));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(brush, null, geometry);
|
||||
}
|
||||
var coupling = new Pen(Palette.Warn, 2);
|
||||
foreach (var c in recording.UserCouplings())
|
||||
if (c.Time >= _start && c.Time <= end)
|
||||
context.DrawLine(coupling, new Point(X(c.Time), top + 1), new Point(X(c.Time), top + MarksHeight));
|
||||
var command = new Pen(Palette.Command, 1);
|
||||
foreach (var c in recording.UserCommands())
|
||||
if (c.Time >= _start && c.Time <= end)
|
||||
context.DrawLine(command, new Point(X(c.Time), top + MarksHeight - 5), new Point(X(c.Time), top + MarksHeight));
|
||||
var autopilot = new Pen(Palette.Autopilot, 2);
|
||||
foreach (var d in recording.DriverChanges)
|
||||
if (d.Time >= _start && d.Time <= end)
|
||||
context.DrawLine(autopilot, new Point(X(d.Time), top + 2), new Point(X(d.Time), top + MarksHeight));
|
||||
foreach (var p in recording.UserPassings())
|
||||
{
|
||||
if (p.Time < _start || p.Time > end)
|
||||
continue;
|
||||
double x = X(p.Time);
|
||||
if (p.AtDanger)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(new Point(x, Top - 4), true);
|
||||
g.LineTo(new Point(x - 5, Top - 13));
|
||||
g.LineTo(new Point(x + 5, Top - 13));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(Palette.Over, null, geometry);
|
||||
context.DrawLine(new Pen(Palette.Over, 1.5), new Point(x, Top - 4), new Point(x, top + MarksHeight));
|
||||
}
|
||||
else
|
||||
context.DrawLine(new Pen(Palette.Within, 1.5), new Point(x, top + 1), new Point(x, top + MarksHeight - 6));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawAxis(DrawingContext context, Recording recording, double y, double end)
|
||||
{
|
||||
double[] steps = { 10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200 };
|
||||
double step = steps[^1];
|
||||
foreach (double candidate in steps)
|
||||
if (candidate / _secondsPerPixel >= 80)
|
||||
{
|
||||
step = candidate;
|
||||
break;
|
||||
}
|
||||
var pen = new Pen(Palette.DimText, 1);
|
||||
// On the clock of the simulation: the marks fall on whole minutes and hours of it.
|
||||
double offset = recording.ClockAt(0).TotalSeconds;
|
||||
double first = Math.Ceiling((_start + offset) / step) * step - offset;
|
||||
for (double t = first; t <= end; t += step)
|
||||
{
|
||||
double x = X(t);
|
||||
context.DrawLine(pen, new Point(x, y), new Point(x, y + 4));
|
||||
var clock = recording.ClockAt(t);
|
||||
string label = step < 60 ? clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
var text = MapView.Text(label, Palette.DimText, 10);
|
||||
context.DrawText(text, new Point(x - text.Width / 2, y + 4));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawKey(DrawingContext context)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the mark under the pointer is: the nearest within a few pixels, in the row of the events or the plates above.</summary>
|
||||
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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>A line through columns of pixels: in each the first, the lowest, the highest and the last value.</summary>
|
||||
private sealed class Curve
|
||||
{
|
||||
private readonly List<List<Point>> _lines = new();
|
||||
private List<Point>? _line;
|
||||
private int _column = int.MinValue;
|
||||
private double _first, _low, _high, _last;
|
||||
|
||||
public void Add(int column, double y)
|
||||
{
|
||||
if (column != _column)
|
||||
{
|
||||
Flush();
|
||||
_column = column;
|
||||
_first = _low = _high = y;
|
||||
}
|
||||
_low = Math.Min(_low, y);
|
||||
_high = Math.Max(_high, y);
|
||||
_last = y;
|
||||
}
|
||||
|
||||
public void Break()
|
||||
{
|
||||
Flush();
|
||||
_line = null;
|
||||
_column = int.MinValue;
|
||||
}
|
||||
|
||||
private void Flush()
|
||||
{
|
||||
if (_column == int.MinValue)
|
||||
return;
|
||||
if (_line is null)
|
||||
_lines.Add(_line = new List<Point>());
|
||||
var (a, b) = _first <= _last ? (_low, _high) : (_high, _low);
|
||||
_line.Add(new Point(_column, _first));
|
||||
if (Math.Abs(_high - _low) > 0.5)
|
||||
{
|
||||
_line.Add(new Point(_column, a));
|
||||
_line.Add(new Point(_column, b));
|
||||
}
|
||||
_line.Add(new Point(_column, _last));
|
||||
}
|
||||
|
||||
/// <summary>The area under the line down to the bottom given, for a soft fill.</summary>
|
||||
public StreamGeometry? Fill(double bottom)
|
||||
{
|
||||
Flush();
|
||||
_column = int.MinValue;
|
||||
var geometry = new StreamGeometry();
|
||||
bool any = false;
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in _lines)
|
||||
{
|
||||
if (line.Count < 2)
|
||||
continue;
|
||||
g.BeginFigure(new Point(line[0].X, bottom), true);
|
||||
foreach (var point in line)
|
||||
g.LineTo(point);
|
||||
g.LineTo(new Point(line[^1].X, bottom));
|
||||
g.EndFigure(true);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? geometry : null;
|
||||
}
|
||||
|
||||
public StreamGeometry? Geometry()
|
||||
{
|
||||
Flush();
|
||||
_column = int.MinValue;
|
||||
var geometry = new StreamGeometry();
|
||||
bool any = false;
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in _lines)
|
||||
{
|
||||
if (line.Count < 2)
|
||||
continue;
|
||||
g.BeginFigure(line[0], false);
|
||||
for (int i = 1; i < line.Count; i++)
|
||||
g.LineTo(line[i]);
|
||||
g.EndFigure(false);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? geometry : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
269
Eu07.BlackBox.Views/TimetablePanel.cs
Normal file
269
Eu07.BlackBox.Views/TimetablePanel.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The timetable of the user's train, a row a station: what the timetable said and what
|
||||
/// happened, by the end of the recording read so far. A stop shows the time of arrival and
|
||||
/// of departure with the delay, how well the train was stopped against the stop point and
|
||||
/// whether it stood along the platform; a station passed through the time of passing. The
|
||||
/// station of the moment played is marked; a click goes to the moment the train was there.
|
||||
/// </summary>
|
||||
public sealed class TimetablePanel : Control
|
||||
{
|
||||
private const double Pad = 12;
|
||||
private const double HeaderHeight = 50;
|
||||
private const double RowHeight = 54;
|
||||
|
||||
private Recording? _recording;
|
||||
private double _time;
|
||||
private int _builtRevision = -1;
|
||||
private IReadOnlyList<StationProgress> _rows = Array.Empty<StationProgress>();
|
||||
private Timetable? _timetable;
|
||||
private int _hover = -1;
|
||||
|
||||
public event Action<double>? Seek;
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public TimetablePanel()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_builtRevision = -1;
|
||||
Rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
Rebuild();
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
if (_recording is not { } recording)
|
||||
{
|
||||
_rows = Array.Empty<StationProgress>();
|
||||
_timetable = null;
|
||||
InvalidateMeasure();
|
||||
return;
|
||||
}
|
||||
var timetable = recording.TimetableAt(_time);
|
||||
if (recording.Revision == _builtRevision && ReferenceEquals(timetable, _timetable))
|
||||
return;
|
||||
_builtRevision = recording.Revision;
|
||||
_timetable = timetable;
|
||||
_rows = StationProgress.Of(recording, _time);
|
||||
InvalidateMeasure();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) =>
|
||||
new(0, _timetable is null ? 80 : HeaderHeight + _rows.Count * RowHeight + Pad);
|
||||
|
||||
/// <summary>The row of the station the train was at, or made for, at the moment played.</summary>
|
||||
private int Current()
|
||||
{
|
||||
int current = -1;
|
||||
for (int i = 0; i < _rows.Count; i++)
|
||||
{
|
||||
var row = _rows[i];
|
||||
double? at = row.ArrivedAt ?? row.DepartedAt;
|
||||
if (at is { } t && t <= _time + 0.05)
|
||||
current = i;
|
||||
}
|
||||
// Still standing there, or on the way to the next one.
|
||||
if (current >= 0 && _rows[current].DepartedAt is { } left && left <= _time && current + 1 < _rows.Count)
|
||||
return current + 1;
|
||||
return current < 0 ? (_rows.Count > 0 ? 0 : -1) : current;
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
if (_timetable is not { } timetable)
|
||||
{
|
||||
context.DrawText(MapView.Text(Strings["TimetableNone"], Palette.DimText, 12), new Point(Pad, Pad));
|
||||
return;
|
||||
}
|
||||
|
||||
// The train: its number and category, from where to where.
|
||||
var train = MapView.Text($"{timetable.Category} {timetable.Train}".Trim(), Palette.Text, 15, FontWeight.SemiBold);
|
||||
context.DrawText(train, new Point(Pad, 6));
|
||||
string relation = string.Format(culture, Strings["Relation"], timetable.From, timetable.To);
|
||||
if (timetable.Vmax > 0)
|
||||
relation += $" · {timetable.Vmax:0} km/h";
|
||||
context.DrawText(MapView.Text(relation, Palette.DimText, 11), new Point(Pad, 6 + train.Height + 1));
|
||||
|
||||
int current = Current();
|
||||
double rail = Pad + 6;
|
||||
for (int i = 0; i < _rows.Count; i++)
|
||||
{
|
||||
double top = HeaderHeight + i * RowHeight;
|
||||
var row = _rows[i];
|
||||
if (i == current)
|
||||
{
|
||||
context.DrawRectangle(Palette.CardHover, null, new Rect(0, top, size.Width, RowHeight - 1));
|
||||
context.DrawRectangle(Palette.Accent, null, new Rect(0, top, 2, RowHeight - 1));
|
||||
}
|
||||
else if (i == _hover)
|
||||
context.DrawRectangle(Palette.Faded(Palette.CardHover, 0.6), null, new Rect(0, top, size.Width, RowHeight - 1));
|
||||
|
||||
// The line of the route through the stations, a dot each: hollow ahead, filled when done.
|
||||
var outcome = row.Outcome;
|
||||
var colour = outcome switch
|
||||
{
|
||||
StationOutcome.Missed => Palette.Bad,
|
||||
StationOutcome.Ahead => Palette.FaintText,
|
||||
_ => DelayBrush(row.ArrivalDelay ?? row.DepartureDelay)
|
||||
};
|
||||
double dotY = top + 15;
|
||||
if (i + 1 < _rows.Count)
|
||||
context.DrawLine(new Pen(Palette.Grid, 2), new Point(rail, dotY), new Point(rail, dotY + RowHeight));
|
||||
bool stop = row.Row.IsStop;
|
||||
double radius = stop ? 5.5 : 3.5;
|
||||
context.DrawEllipse(outcome == StationOutcome.Ahead ? Palette.Card : colour, new Pen(colour, 2), new Point(rail, dotY), radius, radius);
|
||||
|
||||
// The station and what the timetable said.
|
||||
double x = rail + 16;
|
||||
var name = MapView.Text(StationNames.Display(row.Row.Station), outcome == StationOutcome.Ahead ? Palette.DimText : Palette.Text, 13,
|
||||
stop ? FontWeight.SemiBold : FontWeight.Normal);
|
||||
context.DrawText(name, new Point(x, dotY - name.Height / 2));
|
||||
string planned = stop
|
||||
? $"{Minutes(row.Row.Arrival)} → {Minutes(row.Row.Departure)}"
|
||||
: Minutes(row.Row.Departure);
|
||||
var plan = MapView.Text(planned, Palette.DimText, 12);
|
||||
context.DrawText(plan, new Point(size.Width - Pad - plan.Width, dotY - plan.Height / 2));
|
||||
if (row.Row.Km > 0 || i > 0)
|
||||
{
|
||||
var km = MapView.Text($"km {row.Row.Km:0.0}", Palette.FaintText, 10);
|
||||
double kmX = size.Width - Pad - plan.Width - 12 - km.Width;
|
||||
if (kmX > x + name.Width + 8)
|
||||
context.DrawText(km, new Point(kmX, dotY - km.Height / 2));
|
||||
}
|
||||
|
||||
// What happened there, under it: each part in its colour, on to a second line when long.
|
||||
double partX = x, partY = top + 25;
|
||||
bool first = true;
|
||||
foreach (var (text, brush) in Chips(row, culture))
|
||||
{
|
||||
var part = MapView.Text(first ? text : "· " + text, brush, 11);
|
||||
if (!first && partX + part.Width > size.Width - Pad)
|
||||
{
|
||||
partX = x;
|
||||
partY += 14;
|
||||
part = MapView.Text(text, brush, 11);
|
||||
}
|
||||
context.DrawText(part, new Point(partX, partY));
|
||||
partX += part.Width + 6;
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What happened at a station, each part with its colour.</summary>
|
||||
private IEnumerable<(string Text, IBrush Brush)> Chips(StationProgress row, CultureInfo culture)
|
||||
{
|
||||
switch (row.Outcome)
|
||||
{
|
||||
case StationOutcome.Ahead:
|
||||
if (row.Row.Maintenance)
|
||||
yield return (Strings["TtTechnical"], Palette.FaintText);
|
||||
yield break;
|
||||
case StationOutcome.Passed:
|
||||
yield return (string.Format(culture, Strings["TtPassed"], Clock(row.DepartureClock)), Palette.DimText);
|
||||
if (row.DepartureDelay is { } passDelay)
|
||||
yield return (string.Format(culture, Strings["Minutes"], passDelay), DelayBrush(passDelay));
|
||||
yield break;
|
||||
}
|
||||
if (row.ArrivedAt is null)
|
||||
{
|
||||
yield return (Strings["TtMissed"], Palette.Bad);
|
||||
yield break;
|
||||
}
|
||||
yield return (string.Format(culture, Strings["TtArrived"], Clock(row.ArrivalClock)), Palette.DimText);
|
||||
if (row.ArrivalDelay is { } arrival)
|
||||
yield return (string.Format(culture, Strings["Minutes"], arrival), DelayBrush(arrival));
|
||||
if (row.Outcome == StationOutcome.Missed)
|
||||
yield return (Strings["TtLeftEarly"], Palette.Bad);
|
||||
else if (row.DepartedAt is not null)
|
||||
{
|
||||
yield return (string.Format(culture, Strings["TtDeparted"], Clock(row.DepartureClock)), Palette.DimText);
|
||||
if (row.DepartureDelay is { } departure)
|
||||
yield return (string.Format(culture, Strings["Minutes"], departure), DelayBrush(departure));
|
||||
}
|
||||
else
|
||||
yield return (Strings["TtStanding"], Palette.Station);
|
||||
if (row.StopError is { } error)
|
||||
yield return Math.Abs(error) < 1
|
||||
? (Strings["TtStopExact"], Palette.Good)
|
||||
: (string.Format(culture, Strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error)),
|
||||
Math.Abs(error) <= 5 ? Palette.Good : Math.Abs(error) <= 15 ? Palette.Warn : Palette.Bad);
|
||||
if (row.OnPlatform is { } along)
|
||||
yield return along
|
||||
? (Strings["TtOnPlatform"], Palette.Good)
|
||||
: (string.Format(culture, Strings["TtOffPlatform"], row.OffPlatform ?? 0), Palette.Bad);
|
||||
}
|
||||
|
||||
private static IBrush DelayBrush(double? minutes) => Palette.Delay(minutes);
|
||||
|
||||
private static string Minutes(float minutes) =>
|
||||
minutes < 0 ? "–" : TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Clock(double? minutes) =>
|
||||
minutes is { } m ? TimeSpan.FromMinutes(m % 1440).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : "–";
|
||||
|
||||
private int RowAt(Point p)
|
||||
{
|
||||
int i = (int)Math.Floor((p.Y - HeaderHeight) / RowHeight);
|
||||
return p.Y >= HeaderHeight && i >= 0 && i < _rows.Count ? i : -1;
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
int hover = RowAt(e.GetPosition(this));
|
||||
if (hover != _hover)
|
||||
{
|
||||
_hover = hover;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
_hover = -1;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
int i = RowAt(e.GetPosition(this));
|
||||
if (i >= 0 && (_rows[i].ArrivedAt ?? _rows[i].DepartedAt) is { } t)
|
||||
Seek?.Invoke(Math.Max(0, t - 5));
|
||||
}
|
||||
}
|
||||
283
Eu07.BlackBox.Views/UiKit.cs
Normal file
283
Eu07.BlackBox.Views/UiKit.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces the window is built of, in one plain look: flat panels side by side, flat
|
||||
/// buttons, a table of figures and tabs underlined.
|
||||
/// </summary>
|
||||
internal static class UiKit
|
||||
{
|
||||
/// <summary>The gutter between the panels.</summary>
|
||||
public const double Gap = 6;
|
||||
public static readonly CornerRadius Small = new(2);
|
||||
|
||||
/// <summary>
|
||||
/// The theme's accent (the slider, the picked lines, the focus) in the orange of the viewer
|
||||
/// instead of the system colour, for the window given and everything in it.
|
||||
/// </summary>
|
||||
public static void Accent(Control control)
|
||||
{
|
||||
// As the starter's App.axaml sets them, for the viewer on its own.
|
||||
(string Key, string Colour)[] accents =
|
||||
{
|
||||
("SystemAccentColor", "#177f00"), ("SystemAccentColorDark1", "#136B00"), ("SystemAccentColorDark2", "#0F5600"),
|
||||
("SystemAccentColorDark3", "#0B4200"), ("SystemAccentColorLight1", "#2A9A0F"), ("SystemAccentColorLight2", "#41C400"),
|
||||
("SystemAccentColorLight3", "#5FD42A")
|
||||
};
|
||||
foreach (var (key, colour) in accents)
|
||||
control.Resources[key] = Color.Parse(colour);
|
||||
control.Resources["AccentFillColorDefaultBrush"] = Palette.AccentFill;
|
||||
control.Resources["AccentFillColorSecondaryBrush"] = Palette.AccentFillHover;
|
||||
control.Resources["SystemControlHighlightListAccentLowBrush"] = Palette.AccentFill;
|
||||
control.Resources["SystemControlHighlightListAccentMediumBrush"] = Palette.AccentFillHover;
|
||||
control.Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
|
||||
control.Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
|
||||
control.Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
|
||||
control.Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
|
||||
}
|
||||
|
||||
/// <summary>A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.</summary>
|
||||
public static Border Group(string title, Control content, Control? tag = null)
|
||||
{
|
||||
var bar = new DockPanel();
|
||||
if (tag is not null)
|
||||
{
|
||||
DockPanel.SetDock(tag, Dock.Right);
|
||||
tag.VerticalAlignment = VerticalAlignment.Center;
|
||||
bar.Children.Add(tag);
|
||||
}
|
||||
bar.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Palette.Text,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
var header = new Border
|
||||
{
|
||||
Background = Palette.Header,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(8, 3),
|
||||
MinHeight = 24,
|
||||
Child = bar
|
||||
};
|
||||
var panel = new DockPanel();
|
||||
DockPanel.SetDock(header, Dock.Top);
|
||||
panel.Children.Add(header);
|
||||
panel.Children.Add(content);
|
||||
return Frame(panel);
|
||||
}
|
||||
|
||||
/// <summary>A panel in the frame of the starter's group boxes, without a title.</summary>
|
||||
public static Border Frame(Control content) => new()
|
||||
{
|
||||
Background = Palette.Panel,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = Small,
|
||||
ClipToBounds = true,
|
||||
Child = content
|
||||
};
|
||||
|
||||
/// <summary>A flat panel the views sit on.</summary>
|
||||
public static Border Card(Control content, Thickness? padding = null) => new()
|
||||
{
|
||||
Background = Palette.Card,
|
||||
Padding = padding ?? new Thickness(0),
|
||||
ClipToBounds = true,
|
||||
Child = content
|
||||
};
|
||||
|
||||
/// <summary>A button of the toolbar: text on the bar, a lighter ground under the pointer.</summary>
|
||||
public static Button Button(string text, string? tip = null)
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 2),
|
||||
MinHeight = 25,
|
||||
CornerRadius = Small,
|
||||
FontSize = 12,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
if (tip is not null)
|
||||
ToolTip.SetTip(button, tip);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>The button of playing.</summary>
|
||||
public static Button PlayButton()
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Width = 32,
|
||||
Height = 25,
|
||||
CornerRadius = Small,
|
||||
Background = Palette.AccentFill,
|
||||
Padding = new Thickness(0),
|
||||
BorderThickness = new Thickness(0),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
// As the starter's start button: its green, brighter under the pointer.
|
||||
Hover(button, Palette.Accent, Brushes.White);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>The glyph of play or pause.</summary>
|
||||
public static Control PlayGlyph(bool playing)
|
||||
{
|
||||
var geometry = playing
|
||||
? Geometry.Parse("M0,0 H4 V12 H0 Z M7,0 H11 V12 H7 Z")
|
||||
: Geometry.Parse("M1,0 L11,6 L1,12 Z");
|
||||
return new Avalonia.Controls.Shapes.Path
|
||||
{
|
||||
Data = geometry,
|
||||
Fill = Brushes.White,
|
||||
Width = 12,
|
||||
Height = 12,
|
||||
Stretch = Stretch.None,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A toggle of the toolbar: on, its text takes the accent.</summary>
|
||||
public static ToggleButton Toggle(string text, string? tip = null)
|
||||
{
|
||||
var toggle = new ToggleButton
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 2),
|
||||
MinHeight = 25,
|
||||
CornerRadius = Small,
|
||||
FontSize = 12,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
foreach (var state in new[] { "", "PointerOver", "Pressed" })
|
||||
{
|
||||
toggle.Resources["ToggleButtonBackgroundChecked" + state] = state == "PointerOver" ? Palette.AccentFillHover : Palette.AccentFill;
|
||||
toggle.Resources["ToggleButtonForegroundChecked" + state] = Brushes.White;
|
||||
}
|
||||
if (tip is not null)
|
||||
ToolTip.SetTip(toggle, tip);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// The theme takes its own colours under the pointer and when pressed, unless the control gives them.
|
||||
private static void Hover(Control control, IBrush background, IBrush foreground)
|
||||
{
|
||||
foreach (var kind in new[] { "Button", "ToggleButton" })
|
||||
{
|
||||
control.Resources[kind + "BackgroundPointerOver"] = background;
|
||||
control.Resources[kind + "BackgroundPressed"] = background;
|
||||
control.Resources[kind + "ForegroundPointerOver"] = foreground;
|
||||
control.Resources[kind + "ForegroundPressed"] = foreground;
|
||||
control.Resources[kind + "BorderBrushPointerOver"] = Brushes.Transparent;
|
||||
control.Resources[kind + "BorderBrushPressed"] = Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A line of the table of figures: what it is on the left, its value on the right, a note under it.</summary>
|
||||
public sealed class Tile : Border
|
||||
{
|
||||
private readonly TextBlock _value = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, HorizontalAlignment = HorizontalAlignment.Right };
|
||||
private readonly TextBlock _sub = new() { FontSize = 11, Foreground = Palette.FaintText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
|
||||
public Tile(string caption)
|
||||
{
|
||||
BorderBrush = Palette.Grid;
|
||||
BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
Padding = new Thickness(8, 4);
|
||||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), RowDefinitions = new RowDefinitions("Auto,Auto") };
|
||||
var name = new TextBlock { Text = caption, FontSize = 12, Foreground = Palette.DimText, VerticalAlignment = VerticalAlignment.Center };
|
||||
Grid.SetColumn(_value, 1);
|
||||
Grid.SetRow(_sub, 1);
|
||||
Grid.SetColumnSpan(_sub, 2);
|
||||
grid.Children.Add(name);
|
||||
grid.Children.Add(_value);
|
||||
grid.Children.Add(_sub);
|
||||
Child = grid;
|
||||
}
|
||||
|
||||
public void Show(string value, string? sub = null, IBrush? brush = null)
|
||||
{
|
||||
_value.Text = value;
|
||||
_value.Foreground = brush ?? Palette.Text;
|
||||
_sub.Text = sub ?? "";
|
||||
_sub.IsVisible = !string.IsNullOrEmpty(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.</summary>
|
||||
public sealed class Segmented : Border
|
||||
{
|
||||
private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
|
||||
private int _selected = -1;
|
||||
|
||||
public event Action<int>? SelectedChanged;
|
||||
|
||||
public Segmented(params string[] choices)
|
||||
{
|
||||
Background = Palette.Background;
|
||||
BorderBrush = Palette.CardBorder;
|
||||
BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
Child = _row;
|
||||
for (int i = 0; i < choices.Length; i++)
|
||||
{
|
||||
int index = i;
|
||||
var button = new Button
|
||||
{
|
||||
Content = choices[i],
|
||||
Padding = new Thickness(14, 3),
|
||||
MinHeight = 24,
|
||||
CornerRadius = new CornerRadius(0),
|
||||
FontSize = 12,
|
||||
BorderThickness = new Thickness(0, 2, 0, 0)
|
||||
};
|
||||
button.Resources["ButtonBackgroundPointerOver"] = Palette.CardHover;
|
||||
button.Resources["ButtonBackgroundPressed"] = Palette.CardHover;
|
||||
button.Resources["ButtonForegroundPointerOver"] = Palette.Text;
|
||||
button.Resources["ButtonForegroundPressed"] = Palette.Text;
|
||||
button.Click += (_, _) => Selected = index;
|
||||
_row.Children.Add(button);
|
||||
}
|
||||
Selected = 0;
|
||||
}
|
||||
|
||||
public int Selected
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (value == _selected)
|
||||
return;
|
||||
_selected = value;
|
||||
for (int i = 0; i < _row.Children.Count; i++)
|
||||
{
|
||||
var button = (Button)_row.Children[i];
|
||||
bool on = i == value;
|
||||
var line = on ? Palette.Accent : Brushes.Transparent;
|
||||
button.Background = on ? Palette.Panel : Brushes.Transparent;
|
||||
button.BorderBrush = line;
|
||||
button.Resources["ButtonBorderBrushPointerOver"] = line;
|
||||
button.Resources["ButtonBorderBrushPressed"] = line;
|
||||
button.Foreground = on ? Palette.Text : Palette.DimText;
|
||||
}
|
||||
SelectedChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
331
Eu07.BlackBox/BlackBoxReader.cs
Normal file
331
Eu07.BlackBox/BlackBoxReader.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a black box into a <see cref="Recording"/>, 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.
|
||||
/// </summary>
|
||||
public sealed class BlackBoxReader
|
||||
{
|
||||
public static ReadOnlySpan<byte> 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();
|
||||
|
||||
/// <summary>Bytes taken so far: the header and the whole records.</summary>
|
||||
public long Offset => _offset;
|
||||
|
||||
/// <summary>Opens the file shared, as the simulator may be writing it, and reads what is new.</summary>
|
||||
public int ReadMore(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
return ReadMore(stream);
|
||||
}
|
||||
|
||||
/// <summary>Reads the whole records added since the last read. Returns how many were read.</summary>
|
||||
/// <exception cref="InvalidDataException">Not a black box, or of a version this reader doesn't know.</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Reads a whole file at once.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Whether the bytes start as a black box does.</summary>
|
||||
public static bool LooksLikeBlackBox(ReadOnlySpan<byte> start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
|
||||
|
||||
private void ReadHeader(ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The fields of a record in the order they come, little-endian. A field past the end throws.</summary>
|
||||
private ref struct PayloadReader
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _bytes;
|
||||
private int _at;
|
||||
|
||||
public PayloadReader(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
_bytes = bytes;
|
||||
_at = 0;
|
||||
}
|
||||
|
||||
public int Remaining => _bytes.Length - _at;
|
||||
|
||||
private ReadOnlySpan<byte> 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());
|
||||
|
||||
/// <summary>UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
196
Eu07.BlackBox/DrivingSummary.cs
Normal file
196
Eu07.BlackBox/DrivingSummary.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>How a sample of the user's train stands against what the AI would have done.</summary>
|
||||
public enum SpeedJudgement
|
||||
{
|
||||
/// <summary>Nothing to set it against: the consist not started, or the user out of the cab.</summary>
|
||||
NoData,
|
||||
/// <summary>The AI drove.</summary>
|
||||
Autopilot,
|
||||
/// <summary>At most the speed the AI would hold.</summary>
|
||||
Within,
|
||||
/// <summary>Faster, by no more than the tolerance.</summary>
|
||||
Slightly,
|
||||
/// <summary>Faster by more than the tolerance.</summary>
|
||||
Over
|
||||
}
|
||||
|
||||
/// <summary>What the user's driving came to, set against the AI's.</summary>
|
||||
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<SignalPassing> PassedAtDanger,
|
||||
int Commands,
|
||||
int AutopilotSwitches,
|
||||
float MaxAcceleration,
|
||||
float MaxDeceleration,
|
||||
float MaxLateralAcceleration,
|
||||
int Stops,
|
||||
int StopsMissed,
|
||||
double? MaxDelayMinutes,
|
||||
int HornPoints,
|
||||
int HornsMissed,
|
||||
int SecurityBrakings)
|
||||
{
|
||||
/// <summary>The largest delay the user's train left a station or passed it with [min], null without any.</summary>
|
||||
public double? MaxDepartureDelayMinutes { get; init; }
|
||||
|
||||
/// <summary>How long the train moved with its light signals known to be right or wrong [s].</summary>
|
||||
public double LightsJudgedSeconds { get; init; }
|
||||
|
||||
/// <summary>How long of that its head or its tail showed other signals than it was to [s].</summary>
|
||||
public double WrongLightsSeconds { get; init; }
|
||||
|
||||
/// <summary>Each stretch the train moved with the wrong signals, as they were.</summary>
|
||||
public IReadOnlyList<LightsFault> LightsFaults { get; init; } = Array.Empty<LightsFault>();
|
||||
|
||||
/// <summary>The stops where the train did not fit along the platform, and how far its tail stood off it [m].</summary>
|
||||
public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
|
||||
|
||||
/// <summary>[km/h] over the speed of the AI before it counts as well over.</summary>
|
||||
public const float DefaultTolerance = 5f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LightsFault>();
|
||||
// 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<double>().ToList();
|
||||
var departures = stations.Select(s => s.DepartureDelay).OfType<double>().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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A stretch the user's train moved with other light signals than it was to show, at its head and its tail.</summary>
|
||||
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;
|
||||
}
|
||||
14
Eu07.BlackBox/Eu07.BlackBox.csproj
Normal file
14
Eu07.BlackBox/Eu07.BlackBox.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Reads the black box the simulator writes to physicslog/*.blackbox. Only the
|
||||
base library: the starter, the viewer and any other program can use it, and
|
||||
it goes through NativeAOT and trimming as they do. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>Eu07.BlackBox</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
167
Eu07.BlackBox/HeadCodes.cs
Normal file
167
Eu07.BlackBox/HeadCodes.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>What the driver of the user's train went by: the lights it is to show depend on it.</summary>
|
||||
public enum Movement : byte
|
||||
{
|
||||
/// <summary>No driver, or standing by.</summary>
|
||||
Other = 0,
|
||||
/// <summary>A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.</summary>
|
||||
Train = 1,
|
||||
/// <summary>Shunting: Tb 1 at both ends.</summary>
|
||||
Shunting = 2,
|
||||
/// <summary>A recording made before it was written.</summary>
|
||||
Unknown = 0xFF
|
||||
}
|
||||
|
||||
/// <summary>The light signals at an end of a train.</summary>
|
||||
public enum HeadCode
|
||||
{
|
||||
Off,
|
||||
/// <summary>The head of a train: both headlights and the upper one.</summary>
|
||||
Pc1,
|
||||
/// <summary>The head of a train on the wrong track: the left red marker, the right headlight and the upper one.</summary>
|
||||
Pc2,
|
||||
/// <summary>The tail of a train: both red markers.</summary>
|
||||
Pc5,
|
||||
/// <summary>Shunting: one headlight.</summary>
|
||||
Tb1,
|
||||
/// <summary>Any other pattern.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>The light signals a train shows and those it is to show, as the simulator's driver sets them.</summary>
|
||||
public static class HeadCodes
|
||||
{
|
||||
private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
|
||||
Lights.RedMarkerRight | Lights.RearEndSignals;
|
||||
|
||||
/// <summary>The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.</summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The signal the head of the train is to show, null when nothing tells it.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>The signal the tail of the train is to show, null when nothing tells it.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>Whether the head of the train showed what it was to; null when nothing tells it.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>Whether the tail of the train showed what it was to; null when nothing tells it.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool NoLamps(Lights? equipped) => equipped == Lights.None;
|
||||
|
||||
/// <summary>Whether both ends showed what they were to; null when nothing tells it.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The name of a signal as the rules give it.</summary>
|
||||
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 => "—",
|
||||
_ => "?"
|
||||
};
|
||||
}
|
||||
532
Eu07.BlackBox/Recording.cs
Normal file
532
Eu07.BlackBox/Recording.cs
Normal file
@@ -0,0 +1,532 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>A span of time, start included, end not.</summary>
|
||||
public readonly record struct TimeSpanRange(double Start, double End)
|
||||
{
|
||||
public double Length => End - Start;
|
||||
public bool Contains(double t) => t >= Start && t < End;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class Recording
|
||||
{
|
||||
private readonly List<MapTrack> _tracks = new();
|
||||
private readonly Dictionary<int, List<MapTrack>> _trackIndex = new();
|
||||
private readonly List<MapSignal> _signals = new();
|
||||
private readonly Dictionary<int, MapSignal> _signalIndex = new();
|
||||
private readonly Dictionary<int, VehicleInfo> _vehicles = new();
|
||||
private readonly List<CabSample> _cab = new();
|
||||
private readonly List<VehicleLook> _looks = new();
|
||||
private readonly Dictionary<int, List<(double Time, VehicleState State)>> _history = new();
|
||||
private readonly List<SwitchChange> _switches = new();
|
||||
private readonly Dictionary<int, List<SwitchChange>> _switchIndex = new();
|
||||
private readonly List<AspectChange> _aspects = new();
|
||||
private readonly Dictionary<int, List<AspectChange>> _aspectIndex = new();
|
||||
private readonly List<CommandEntry> _commands = new();
|
||||
private readonly List<DriverChange> _driverChanges = new();
|
||||
private readonly List<SignalPassing> _passings = new();
|
||||
private readonly List<UserVehicleChange> _userVehicles = new();
|
||||
private readonly List<ClockEntry> _clocks = new();
|
||||
private readonly List<MapStopPoint> _stopPoints = new();
|
||||
private readonly List<MapHornPoint> _hornPoints = new();
|
||||
private readonly List<Timetable> _timetables = new();
|
||||
private readonly List<StationEvent> _stations = new();
|
||||
private readonly List<HornRequest> _hornRequests = new();
|
||||
private readonly List<CameraState> _cameras = new();
|
||||
private readonly List<CouplingChange> _couplings = new();
|
||||
private double _lastTime;
|
||||
|
||||
public int Version { get; internal set; }
|
||||
public RecordingInfo? Info { get; internal set; }
|
||||
|
||||
/// <summary>The time of the end record: null while the simulator still writes, or if it didn't end the recording.</summary>
|
||||
public double? EndTime { get; internal set; }
|
||||
|
||||
public bool Complete => EndTime is not null;
|
||||
|
||||
/// <summary>Records of types this reader doesn't know, passed over.</summary>
|
||||
public int Unknown { get; internal set; }
|
||||
|
||||
/// <summary>Records shorter than their type says, passed over.</summary>
|
||||
public int Malformed { get; internal set; }
|
||||
|
||||
/// <summary>Goes up with every read that added something, for views to tell they have to look again.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
public IReadOnlyList<MapTrack> Tracks => _tracks;
|
||||
public IReadOnlyList<MapSignal> Signals => _signals;
|
||||
public IReadOnlyDictionary<int, VehicleInfo> Vehicles => _vehicles;
|
||||
public IReadOnlyList<CabSample> Cab => _cab;
|
||||
public IReadOnlyList<VehicleLook> Looks => _looks;
|
||||
public IReadOnlyList<SwitchChange> Switches => _switches;
|
||||
public IReadOnlyList<AspectChange> Aspects => _aspects;
|
||||
public IReadOnlyList<CommandEntry> Commands => _commands;
|
||||
public IReadOnlyList<DriverChange> DriverChanges => _driverChanges;
|
||||
public IReadOnlyList<SignalPassing> Passings => _passings;
|
||||
public IReadOnlyList<UserVehicleChange> UserVehicles => _userVehicles;
|
||||
public IReadOnlyList<ClockEntry> Clocks => _clocks;
|
||||
public IReadOnlyList<MapStopPoint> StopPoints => _stopPoints;
|
||||
public IReadOnlyList<MapHornPoint> HornPoints => _hornPoints;
|
||||
public IReadOnlyList<Timetable> Timetables => _timetables;
|
||||
public IReadOnlyList<StationEvent> Stations => _stations;
|
||||
public IReadOnlyList<HornRequest> HornRequests => _hornRequests;
|
||||
public IReadOnlyList<CameraState> Cameras => _cameras;
|
||||
public IReadOnlyList<CouplingChange> Couplings => _couplings;
|
||||
|
||||
/// <summary>The time the recording covers so far.</summary>
|
||||
public double Duration => Math.Max(EndTime ?? 0, _lastTime);
|
||||
|
||||
/// <summary>The seconds between two looks at the vehicles.</summary>
|
||||
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<object, object> _memo = new();
|
||||
private readonly Dictionary<(int Vehicle, int Coupling), IReadOnlySet<int>> _consists = new();
|
||||
private static readonly object UserStationsKey = new(), UserPassingsKey = new(), UserHornChecksKey = new(), UserCouplingsKey = new();
|
||||
|
||||
/// <summary>What make gives, worked out once until the recording changes.</summary>
|
||||
internal T Memo<T>(object key, Func<T> make) where T : class
|
||||
{
|
||||
if (_memo.TryGetValue(key, out var kept))
|
||||
return (T)kept;
|
||||
var made = make();
|
||||
_memo[key] = made;
|
||||
return made;
|
||||
}
|
||||
|
||||
private void Saw(double time) => _lastTime = Math.Max(_lastTime, time);
|
||||
|
||||
internal void AddTrack(MapTrack track)
|
||||
{
|
||||
_tracks.Add(track);
|
||||
if (!_trackIndex.TryGetValue(track.Index, out var ways))
|
||||
_trackIndex[track.Index] = ways = new List<MapTrack>(1);
|
||||
ways.Add(track);
|
||||
}
|
||||
|
||||
internal void AddSignal(MapSignal signal)
|
||||
{
|
||||
_signals.Add(signal);
|
||||
_signalIndex[signal.Index] = signal;
|
||||
}
|
||||
|
||||
internal void AddVehicle(VehicleInfo vehicle) => _vehicles[vehicle.Id] = vehicle;
|
||||
|
||||
internal void RemoveVehicle(double time, int id)
|
||||
{
|
||||
Saw(time);
|
||||
if (_vehicles.TryGetValue(id, out var vehicle))
|
||||
vehicle.RemovedAt = time;
|
||||
}
|
||||
|
||||
internal void AddCab(CabSample sample)
|
||||
{
|
||||
Saw(sample.Time);
|
||||
_cab.Add(sample);
|
||||
}
|
||||
|
||||
internal void AddLook(VehicleLook look)
|
||||
{
|
||||
Saw(look.Time);
|
||||
_looks.Add(look);
|
||||
foreach (var state in look.States)
|
||||
{
|
||||
if (!_history.TryGetValue(state.Id, out var history))
|
||||
_history[state.Id] = history = new List<(double, VehicleState)>();
|
||||
history.Add((look.Time, state));
|
||||
}
|
||||
}
|
||||
|
||||
internal void AddSwitch(SwitchChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_switches.Add(change);
|
||||
if (!_switchIndex.TryGetValue(change.Track, out var list))
|
||||
_switchIndex[change.Track] = list = new List<SwitchChange>();
|
||||
list.Add(change);
|
||||
}
|
||||
|
||||
internal void AddAspect(AspectChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_aspects.Add(change);
|
||||
if (!_aspectIndex.TryGetValue(change.Signal, out var list))
|
||||
_aspectIndex[change.Signal] = list = new List<AspectChange>();
|
||||
list.Add(change);
|
||||
}
|
||||
|
||||
internal void AddCommand(CommandEntry command)
|
||||
{
|
||||
Saw(command.Time);
|
||||
_commands.Add(command);
|
||||
}
|
||||
|
||||
internal void AddDriverChange(DriverChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_driverChanges.Add(change);
|
||||
}
|
||||
|
||||
internal void AddPassing(SignalPassing passing)
|
||||
{
|
||||
Saw(passing.Time);
|
||||
_passings.Add(passing);
|
||||
}
|
||||
|
||||
internal void AddUserVehicle(UserVehicleChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_userVehicles.Add(change);
|
||||
}
|
||||
|
||||
internal void AddClock(ClockEntry clock)
|
||||
{
|
||||
Saw(clock.Time);
|
||||
_clocks.Add(clock);
|
||||
}
|
||||
|
||||
internal void AddStopPoint(MapStopPoint point) => _stopPoints.Add(point);
|
||||
|
||||
internal void AddHornPoint(MapHornPoint point) => _hornPoints.Add(point);
|
||||
|
||||
internal void AddTimetable(Timetable timetable)
|
||||
{
|
||||
Saw(timetable.Time);
|
||||
_timetables.Add(timetable);
|
||||
}
|
||||
|
||||
internal void AddStation(StationEvent station)
|
||||
{
|
||||
Saw(station.Time);
|
||||
_stations.Add(station);
|
||||
}
|
||||
|
||||
internal void AddCamera(CameraState camera)
|
||||
{
|
||||
Saw(camera.Time);
|
||||
_cameras.Add(camera);
|
||||
}
|
||||
|
||||
internal void AddCoupling(CouplingChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_couplings.Add(change);
|
||||
}
|
||||
|
||||
internal void AddHornRequest(HornRequest request)
|
||||
{
|
||||
Saw(request.Time);
|
||||
_hornRequests.Add(request);
|
||||
}
|
||||
|
||||
// ----- queries -----
|
||||
|
||||
public IReadOnlyList<MapTrack> TrackWays(int index) =>
|
||||
_trackIndex.TryGetValue(index, out var ways) ? ways : Array.Empty<MapTrack>();
|
||||
|
||||
public MapSignal? Signal(int index) => _signalIndex.GetValueOrDefault(index);
|
||||
|
||||
public VehicleInfo? Vehicle(int id) => _vehicles.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>The last of a list sorted by time at or before t: -1 if none is.</summary>
|
||||
private static int LastAtOrBefore<T>(IReadOnlyList<T> list, double t, Func<T, double> time)
|
||||
{
|
||||
int low = 0, high = list.Count - 1, found = -1;
|
||||
while (low <= high)
|
||||
{
|
||||
int middle = low + (high - low) / 2;
|
||||
if (time(list[middle]) <= t)
|
||||
{
|
||||
found = middle;
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>The index of the sample of the user's train at or before t, -1 before the first.</summary>
|
||||
public int CabIndexAt(double t) => LastAtOrBefore(_cab, t, s => s.Time);
|
||||
|
||||
/// <summary>The sample of the user's train at or before t.</summary>
|
||||
public CabSample? CabAt(double t) => CabIndexAt(t) is var i and >= 0 ? _cab[i] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Where the user's train was at t, between the two samples around it. Null before
|
||||
/// the first sample, and in a gap: the user out of any vehicle.
|
||||
/// </summary>
|
||||
public Position? CabPositionAt(double t)
|
||||
{
|
||||
int i = CabIndexAt(t);
|
||||
if (i < 0)
|
||||
return null;
|
||||
var before = _cab[i];
|
||||
if (i + 1 >= _cab.Count)
|
||||
return t - before.Time <= 1.0 ? before.Position : null;
|
||||
var after = _cab[i + 1];
|
||||
if (after.Vehicle != before.Vehicle || after.Time - before.Time > 1.0)
|
||||
return t - before.Time <= 1.0 ? before.Position : null;
|
||||
double f = (t - before.Time) / Math.Max(1e-9, after.Time - before.Time);
|
||||
return Position.Lerp(before.Position, after.Position, Math.Clamp(f, 0, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every vehicle on the scenery at t, where it was. A vehicle is listed only when it
|
||||
/// moved, so between two listings far apart it stood at the first place until the
|
||||
/// look before the second.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VehicleState> VehiclesAt(double t)
|
||||
{
|
||||
var states = new List<VehicleState>(_history.Count);
|
||||
double interval = LookInterval;
|
||||
foreach (var (id, history) in _history)
|
||||
{
|
||||
if (_vehicles.TryGetValue(id, out var info) && info.RemovedAt is { } removed && removed <= t)
|
||||
continue;
|
||||
int i = LastAtOrBefore(history, t, entry => entry.Time);
|
||||
if (i < 0)
|
||||
continue;
|
||||
var (time, state) = history[i];
|
||||
if (i + 1 < history.Count)
|
||||
{
|
||||
var (nextTime, next) = history[i + 1];
|
||||
double from = Math.Max(time, nextTime - interval * 1.5);
|
||||
if (t > from)
|
||||
state = Lerp(state, next, (t - from) / Math.Max(1e-9, nextTime - from));
|
||||
}
|
||||
states.Add(state);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
private static VehicleState Lerp(VehicleState a, VehicleState b, double f)
|
||||
{
|
||||
float k = (float)Math.Clamp(f, 0, 1);
|
||||
float turn = b.Heading - a.Heading;
|
||||
while (turn > MathF.PI) turn -= 2 * MathF.PI;
|
||||
while (turn < -MathF.PI) turn += 2 * MathF.PI;
|
||||
return a with
|
||||
{
|
||||
Position = Vector3.Lerp(a.Position, b.Position, k),
|
||||
Heading = a.Heading + turn * k,
|
||||
SpeedKmh = a.SpeedKmh + (b.SpeedKmh - a.SpeedKmh) * k,
|
||||
Driving = k < 0.5f ? a.Driving : b.Driving
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The aspect a signal showed at t, null before it was first seen.</summary>
|
||||
public AspectChange? AspectAt(int signal, double t) =>
|
||||
_aspectIndex.TryGetValue(signal, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
|
||||
? list[i]
|
||||
: null;
|
||||
|
||||
/// <summary>The position of a switch at t, -1 if unknown.</summary>
|
||||
public int SwitchAt(int track, double t) =>
|
||||
_switchIndex.TryGetValue(track, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
|
||||
? list[i].State
|
||||
: -1;
|
||||
|
||||
/// <summary>The time of day of the simulation at t.</summary>
|
||||
public TimeSpan ClockAt(double t)
|
||||
{
|
||||
int i = LastAtOrBefore(_clocks, t, c => c.Time);
|
||||
double second = i >= 0
|
||||
? _clocks[i].SecondOfDay + (t - _clocks[i].Time)
|
||||
: (Info?.SecondOfDay ?? 0) + t;
|
||||
second %= 86400;
|
||||
if (second < 0)
|
||||
second += 86400;
|
||||
return TimeSpan.FromSeconds(second);
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the user was in at t, and whether in its cab; null before it was known.</summary>
|
||||
public UserVehicleChange? UserVehicleAt(double t) =>
|
||||
LastAtOrBefore(_userVehicles, t, c => c.Time) is var i and >= 0 ? _userVehicles[i] : null;
|
||||
|
||||
/// <summary>The stretches of the samples of the user's train the condition holds over.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> CabSpans(Func<CabSample, bool> condition)
|
||||
{
|
||||
var spans = new List<TimeSpanRange>();
|
||||
double? start = null;
|
||||
double gap = (Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
|
||||
for (int i = 0; i < _cab.Count; i++)
|
||||
{
|
||||
var sample = _cab[i];
|
||||
bool holds = condition(sample);
|
||||
bool broken = i > 0 && sample.Time - _cab[i - 1].Time > gap;
|
||||
if (start is { } open && (!holds || broken))
|
||||
{
|
||||
spans.Add(new TimeSpanRange(open, broken ? _cab[i - 1].Time : sample.Time));
|
||||
start = null;
|
||||
}
|
||||
if (holds && start is null)
|
||||
start = sample.Time;
|
||||
}
|
||||
if (start is { } last && _cab.Count > 0)
|
||||
spans.Add(new TimeSpanRange(last, _cab[^1].Time));
|
||||
return spans;
|
||||
}
|
||||
|
||||
/// <summary>When the AI drove the user's train: the autopilot.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> AutopilotSpans() => CabSpans(s => s.Driving == Driver.Ai);
|
||||
|
||||
/// <summary>The signals the user's train passed: those passed by the vehicle the samples are of at the time.</summary>
|
||||
public IReadOnlyList<SignalPassing> UserPassings() =>
|
||||
Memo(UserPassingsKey, () => _passings.Where(p => IsUserTrain(p.Time + 0.5, p.Vehicle, p.Position, Math.Max(0, p.Overrun) + 250)).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Whether a vehicle was of the user's train at t. The records of a train give the
|
||||
/// vehicle at its head, which is not the one the user is in when the train is driven
|
||||
/// from its other end or from a car in the middle: those count when the place the
|
||||
/// record is about lies within reach of the user's vehicle.
|
||||
/// </summary>
|
||||
public bool IsUserTrain(double t, int vehicle, Position place, double reach)
|
||||
{
|
||||
if (CabAt(t) is not { } s || t - s.Time > 1.5)
|
||||
return false;
|
||||
if (s.Vehicle == vehicle)
|
||||
return true;
|
||||
// The couplings tell the train exactly; a recording made before them has the distance only.
|
||||
return CouplingsWritten ? ConsistAt(s.Vehicle, t).Contains(vehicle) : s.Position.DistanceTo(place) <= reach;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the couplings were written: a recording made since has the movement in its cab samples, and one with
|
||||
/// no train of more than one vehicle has no coupling at all.
|
||||
/// </summary>
|
||||
private bool CouplingsWritten => _couplings.Count > 0 || (_cab.Count > 0 && _cab[0].Movement != Movement.Unknown);
|
||||
|
||||
/// <summary>The vehicles coupled into one train with the one given at t, itself included.</summary>
|
||||
public IReadOnlySet<int> ConsistAt(int vehicle, double t)
|
||||
{
|
||||
// The trains change only at a coupling: the consist is the same until the next one.
|
||||
int last = LastAtOrBefore(_couplings, t, c => c.Time);
|
||||
if (_consists.TryGetValue((vehicle, last), out var kept))
|
||||
return kept;
|
||||
var consist = Consist(vehicle, last);
|
||||
_consists[(vehicle, last)] = consist;
|
||||
return consist;
|
||||
}
|
||||
|
||||
private HashSet<int> Consist(int vehicle, int last)
|
||||
{
|
||||
// What each coupler held after the coupling given: the couplings in the order written, each telling both ends.
|
||||
var held = new Dictionary<(int Vehicle, int Side), (int Other, int OtherSide)>();
|
||||
for (int i = 0; i <= last; i++)
|
||||
{
|
||||
var change = _couplings[i];
|
||||
if (held.Remove((change.Vehicle, change.Side), out var before))
|
||||
held.Remove((before.Other, before.OtherSide));
|
||||
if (change.Other != 0 && (change.Flags & CouplingFlags.Coupler) != 0)
|
||||
{
|
||||
held[(change.Vehicle, change.Side)] = (change.Other, change.OtherSide);
|
||||
held[(change.Other, change.OtherSide)] = (change.Vehicle, change.Side);
|
||||
}
|
||||
}
|
||||
var consist = new HashSet<int> { vehicle };
|
||||
var open = new Stack<int>();
|
||||
open.Push(vehicle);
|
||||
while (open.Count > 0)
|
||||
{
|
||||
int at = open.Pop();
|
||||
for (int side = 0; side < 2; side++)
|
||||
if (held.TryGetValue((at, side), out var next) && consist.Add(next.Other))
|
||||
open.Push(next.Other);
|
||||
}
|
||||
return consist;
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from at t, null before it was known.</summary>
|
||||
public CameraState? CameraAt(double t) =>
|
||||
LastAtOrBefore(_cameras, t, c => c.Time) is var i and >= 0 ? _cameras[i] : null;
|
||||
|
||||
/// <summary>
|
||||
/// The couplings made and undone after the start: those at t = 0 are what the trains
|
||||
/// were made of, not something done.
|
||||
/// </summary>
|
||||
public IEnumerable<CouplingChange> CouplingEvents() =>
|
||||
_couplings.Where(c => c.Time > 0.01 && (c.Coupled || c.Uncoupled || c.Flags != c.PreviousFlags));
|
||||
|
||||
/// <summary>The couplings the user's train took part in, before or after them.</summary>
|
||||
public IReadOnlyList<CouplingChange> UserCouplings() =>
|
||||
Memo(UserCouplingsKey, () => CouplingEvents().Where(c => CabAt(c.Time) is { } s && c.Time - s.Time <= 1.5 &&
|
||||
(ConsistAt(s.Vehicle, c.Time - 0.01).Contains(c.Vehicle) ||
|
||||
ConsistAt(s.Vehicle, c.Time + 0.01).Contains(c.Vehicle)))
|
||||
.ToList());
|
||||
|
||||
/// <summary>
|
||||
/// The timetable of the train the user was in at t: the last one written by then, else the first; none when the
|
||||
/// user had gone over to a vehicle of another consist, a train without a timetable or one standing on its own.
|
||||
/// </summary>
|
||||
public Timetable? TimetableAt(double t)
|
||||
{
|
||||
var timetable = LastAtOrBefore(_timetables, t, x => x.Time) is var i and >= 0 ? _timetables[i] : _timetables.FirstOrDefault();
|
||||
// A recording made before the couplings were written can't tell the trains apart: the timetable stays.
|
||||
if (timetable is null || CabAt(t) is not { } sample || sample.Vehicle == timetable.Vehicle || !CouplingsWritten)
|
||||
return timetable;
|
||||
// another vehicle of the same train (the other cab, a car of it) keeps the timetable
|
||||
return ConsistAt(sample.Vehicle, t).Contains(timetable.Vehicle) ? timetable : null;
|
||||
}
|
||||
|
||||
/// <summary>The stations of the timetable of the user's train, as it came to them.</summary>
|
||||
public IReadOnlyList<StationEvent> UserStations() =>
|
||||
Memo(UserStationsKey, () => _stations.Where(s => IsUserTrain(s.Time, s.Vehicle, s.Position, Math.Max(s.TrainLength, 50) + 150)).ToList());
|
||||
|
||||
/// <summary>The horn points the user's train passed, and whether the horn sounded there.</summary>
|
||||
public IReadOnlyList<HornCheck> UserHornChecks() => Memo(UserHornChecksKey, () => HornChecks().ToList());
|
||||
|
||||
private IEnumerable<HornCheck> HornChecks()
|
||||
{
|
||||
foreach (var request in _hornRequests)
|
||||
{
|
||||
if (!IsUserTrain(request.Time, request.Vehicle, request.Position, 300))
|
||||
continue;
|
||||
bool autopilot = CabAt(request.Time) is { Driving: Driver.Ai };
|
||||
yield return new HornCheck(request, HornSounded(request), autopilot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The time given to sound the horn after a horn point, beyond how long it is to sound.</summary>
|
||||
public const double HornGrace = 8;
|
||||
|
||||
/// <summary>Whether the horn of the user's train sounded from just before the horn point until a while after.</summary>
|
||||
private bool HornSounded(HornRequest request)
|
||||
{
|
||||
double until = request.Time + Math.Max(request.Duration, 1) + HornGrace;
|
||||
for (int i = Math.Max(0, CabIndexAt(request.Time - 2)); i < _cab.Count && _cab[i].Time <= until; i++)
|
||||
if (_cab[i].Has(CabDevices.Horn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>When the train protection braked the user's train: the alerter or the cab signalling not acknowledged.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> SecurityBrakings() => CabSpans(s => s.Has(SecurityFlags.Braking));
|
||||
|
||||
/// <summary>The commands the user gave in a vehicle, presses only.</summary>
|
||||
public IEnumerable<CommandEntry> UserCommands() => _commands.Where(c => c.Pressed && c.Vehicle != 0);
|
||||
|
||||
public DrivingSummary Summarize(float tolerance = DrivingSummary.DefaultTolerance) => DrivingSummary.Of(this, tolerance);
|
||||
}
|
||||
|
||||
/// <summary>A horn point the user's train passed: whether the horn sounded, and whether the autopilot drove then.</summary>
|
||||
public sealed record HornCheck(HornRequest Request, bool Sounded, bool Autopilot);
|
||||
406
Eu07.BlackBox/Records.cs
Normal file
406
Eu07.BlackBox/Records.cs
Normal file
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>The types of the records, as the simulator writes them (blackbox_doc.md in its repository).</summary>
|
||||
public enum RecordType : byte
|
||||
{
|
||||
Info = 0x01,
|
||||
Track = 0x10,
|
||||
Signal = 0x11,
|
||||
Vehicle = 0x12,
|
||||
VehicleRemoved = 0x13,
|
||||
StopPoint = 0x14,
|
||||
HornPoint = 0x15,
|
||||
Cab = 0x20,
|
||||
Vehicles = 0x21,
|
||||
SwitchState = 0x22,
|
||||
SignalAspect = 0x23,
|
||||
Timetable = 0x24,
|
||||
Camera = 0x25,
|
||||
Command = 0x30,
|
||||
DriverChange = 0x31,
|
||||
SignalPassed = 0x32,
|
||||
UserVehicle = 0x33,
|
||||
Clock = 0x34,
|
||||
Station = 0x35,
|
||||
HornRequest = 0x36,
|
||||
Coupling = 0x37,
|
||||
End = 0x3f
|
||||
}
|
||||
|
||||
/// <summary>Who drives a vehicle.</summary>
|
||||
public enum Driver : byte
|
||||
{
|
||||
None = 0,
|
||||
Human = 1,
|
||||
Ai = 2
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum CabFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>The user is in the cab, not looking around outside.</summary>
|
||||
InCab = 0x01,
|
||||
/// <summary>The consist is started, so what the AI would do is worked out.</summary>
|
||||
AiValid = 0x02,
|
||||
/// <summary>The alerter or the cab signalling blinks.</summary>
|
||||
AlerterBlinking = 0x04,
|
||||
/// <summary>The alerter or the cab signalling beeps.</summary>
|
||||
AlerterBeeping = 0x08,
|
||||
WheelSlip = 0x10,
|
||||
RadioStop = 0x20,
|
||||
AlarmChain = 0x40
|
||||
}
|
||||
|
||||
/// <summary>What is switched on in the user's train.</summary>
|
||||
[Flags]
|
||||
public enum CabDevices : uint
|
||||
{
|
||||
None = 0,
|
||||
Battery = 0x0001,
|
||||
MainBreaker = 0x0002,
|
||||
PantographFront = 0x0004,
|
||||
PantographRear = 0x0008,
|
||||
Converter = 0x0010,
|
||||
Compressor = 0x0020,
|
||||
/// <summary>The motors are connected.</summary>
|
||||
LineContactors = 0x0040,
|
||||
/// <summary>Tripped.</summary>
|
||||
OverloadRelay = 0x0080,
|
||||
Sanding = 0x0100,
|
||||
Horn = 0x0200,
|
||||
DoorsLeft = 0x0400,
|
||||
DoorsRight = 0x0800,
|
||||
Radio = 0x1000,
|
||||
Heating = 0x2000,
|
||||
/// <summary>Applied.</summary>
|
||||
SpringBrake = 0x4000,
|
||||
ShuntMode = 0x8000,
|
||||
/// <summary>Sounding.</summary>
|
||||
DepartureSignal = 0x0001_0000,
|
||||
/// <summary>The doors may be opened.</summary>
|
||||
DoorPermitLeft = 0x0002_0000,
|
||||
DoorPermitRight = 0x0004_0000,
|
||||
/// <summary>The steps come out with the doors.</summary>
|
||||
DoorStep = 0x0008_0000,
|
||||
/// <summary>The doors lock above a speed.</summary>
|
||||
DoorLock = 0x0010_0000,
|
||||
/// <summary>Locked now.</summary>
|
||||
DoorsLocked = 0x0020_0000,
|
||||
/// <summary>Releasing the brakes of the vehicle.</summary>
|
||||
BrakeReleaser = 0x0040_0000,
|
||||
FuelPump = 0x0080_0000,
|
||||
OilPump = 0x0100_0000,
|
||||
/// <summary>The fans of the traction motors.</summary>
|
||||
MotorBlowers = 0x0200_0000,
|
||||
/// <summary>The diesel engine is over its temperature.</summary>
|
||||
EngineHot = 0x0400_0000,
|
||||
/// <summary>The cruise control holds a speed.</summary>
|
||||
SpeedControl = 0x0800_0000
|
||||
}
|
||||
|
||||
/// <summary>What drives the vehicle whose motors the cab drives.</summary>
|
||||
public enum EngineKind : byte
|
||||
{
|
||||
/// <summary>Nothing of its own: a draisine, a car.</summary>
|
||||
None = 0,
|
||||
Electric = 1,
|
||||
ElectricInduction = 2,
|
||||
/// <summary>With a mechanical or a hydraulic transmission.</summary>
|
||||
Diesel = 3,
|
||||
DieselElectric = 4,
|
||||
Steam = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rest of a look at the user's train, written from the same version as the doors' permits: what drives it,
|
||||
/// the brakes' setting and mode, the hand brake, the radio, the horn, the pressure the driver's brake valve shows
|
||||
/// (its equalizing reservoir, the FV4a's time reservoir) and that of the main reservoir pipe, a diesel's oil, temperatures, clutch and torque converter, the cruise control,
|
||||
/// the power and the integrated controller of a unit, the highest brake cylinder pressure of the train. The
|
||||
/// temperatures and the oil mean something for a diesel only.
|
||||
/// </summary>
|
||||
public readonly record struct CabExtra(EngineKind Engine, byte BrakeDelay, byte RadioChannel, float DriverValve,
|
||||
float FeedPipe, float OilPressure, float WaterTemperature, float OilTemperature,
|
||||
float EngineTemperature, float SpeedControlKmh, float EnginePower, sbyte HandBrake,
|
||||
byte BrakeMode, byte Horn, float IntegratedController, float Clutch, float ConverterFill,
|
||||
float ConsistBrakeCylinder);
|
||||
|
||||
/// <summary>The train protection: the alerter (CA) and the cab signalling (SHP) apart.</summary>
|
||||
[Flags]
|
||||
public enum SecurityFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>The alerter lamp blinks: the driver is to acknowledge.</summary>
|
||||
Vigilance = 0x01,
|
||||
/// <summary>The cab signalling lamp blinks: an SHP magnet was passed.</summary>
|
||||
CabSignal = 0x02,
|
||||
VigilanceBeeping = 0x04,
|
||||
CabSignalBeeping = 0x08,
|
||||
/// <summary>Not acknowledged in time: the train protection brakes the train.</summary>
|
||||
Braking = 0x10
|
||||
}
|
||||
|
||||
/// <summary>What a train did at a station of its timetable.</summary>
|
||||
public enum StationKind : byte
|
||||
{
|
||||
/// <summary>Stopped at the stop point of a stop of its timetable.</summary>
|
||||
Stopped = 1,
|
||||
/// <summary>Passed a station where the timetable has no stop.</summary>
|
||||
Passed = 2,
|
||||
/// <summary>Left the stop point behind before it was time to go: without stopping, or leaving early.</summary>
|
||||
Missed = 3,
|
||||
/// <summary>Still at the stop point when it was time to go.</summary>
|
||||
Ready = 4
|
||||
}
|
||||
|
||||
/// <summary>The lights lit at an end of a vehicle.</summary>
|
||||
[Flags]
|
||||
public enum Lights : ushort
|
||||
{
|
||||
None = 0,
|
||||
HeadlightLeft = 0x001,
|
||||
RedMarkerLeft = 0x002,
|
||||
HeadlightUpper = 0x004,
|
||||
HeadlightRight = 0x010,
|
||||
RedMarkerRight = 0x020,
|
||||
RearEndSignals = 0x040,
|
||||
AuxiliaryLeft = 0x080,
|
||||
AuxiliaryRight = 0x100,
|
||||
HighBeamLeft = 0x200,
|
||||
HighBeamRight = 0x400
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum PassingFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>A shunting signal.</summary>
|
||||
Shunt = 0x01,
|
||||
/// <summary>Passed showing stop: a signal passed at danger.</summary>
|
||||
AtStop = 0x02,
|
||||
/// <summary>Stop of an automatic block signal, which may be passed slowly after a stop.</summary>
|
||||
AutomaticBlock = 0x04
|
||||
}
|
||||
|
||||
public enum TrackKind : byte
|
||||
{
|
||||
Normal = 0,
|
||||
SwitchBranch = 1
|
||||
}
|
||||
|
||||
/// <summary>A position in the frame of the scenery: metres, y up.</summary>
|
||||
public readonly record struct Position(double X, double Y, double Z)
|
||||
{
|
||||
public static Position Lerp(Position a, Position b, double f) =>
|
||||
new(a.X + (b.X - a.X) * f, a.Y + (b.Y - a.Y) * f, a.Z + (b.Z - a.Z) * f);
|
||||
|
||||
public double DistanceTo(Position other)
|
||||
{
|
||||
double dx = X - other.X, dy = Y - other.Y, dz = Z - other.Z;
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordingInfo(string Scenery, string Started, int Year, int YearDay, double SecondOfDay,
|
||||
string StartVehicle, string SimulatorVersion, float CabInterval,
|
||||
float VehiclesInterval, float KeyframeInterval)
|
||||
{
|
||||
/// <summary>The time of day the simulation started at.</summary>
|
||||
public TimeSpan StartClock => TimeSpan.FromSeconds(SecondOfDay);
|
||||
}
|
||||
|
||||
/// <summary>One rail track of the map, or one of the two ways of a switch, which share the number.</summary>
|
||||
public sealed record MapTrack(int Index, string Name, TrackKind Kind, int Branch, int Category,
|
||||
IReadOnlyList<Vector3> Points);
|
||||
|
||||
public sealed record MapSignal(int Index, string Name, Position Position);
|
||||
|
||||
/// <summary>
|
||||
/// A stop point of a passenger train (W4). StopAt: below 0 the head stops that many metres
|
||||
/// before it, above 0 the middle of the train. Platform: above 0 its length, below 0 the
|
||||
/// stop is for trains shorter than that (W32), 0 not given; its last digit also tells the side of
|
||||
/// the platform (see <see cref="Platforms"/>).
|
||||
/// </summary>
|
||||
public sealed record MapStopPoint(int Index, string Station, Position Position, float StopAt, float Platform)
|
||||
{
|
||||
/// <summary>
|
||||
/// The heading of the trains it is for [rad, from +z towards +x], NaN when not known: on no track, on tracks of
|
||||
/// both ways, or in a recording made before it was written.
|
||||
/// </summary>
|
||||
public float Heading { get; init; } = float.NaN;
|
||||
|
||||
public bool HasHeading => !float.IsNaN(Heading);
|
||||
|
||||
public bool HasPlatform => Platform > 0;
|
||||
|
||||
/// <summary>A W32: the stop of the trains shorter than its length; the longer ones go on to another stop point.</summary>
|
||||
public bool ForShortTrains => Platform < 0;
|
||||
|
||||
/// <summary>The length of the platform, or of the trains a W32 is for [m].</summary>
|
||||
public double PlatformLength => Platforms.Length(Platform);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The second value of a stop point, as the simulator's driver reads it: the whole of it the metres of the
|
||||
/// platform, and its last digit, besides, the side the doors open on (1, 2, both 3); 201 is 201 m.
|
||||
/// </summary>
|
||||
public static class Platforms
|
||||
{
|
||||
public static double Length(float value) => Math.Abs(value);
|
||||
|
||||
public static int Side(float value) => (int)Math.Floor(Math.Abs(value)) % 10;
|
||||
}
|
||||
|
||||
/// <summary>A horn point (W6): the trains passing it are to sound the horn this long, in this tone (1 low, 2 high).</summary>
|
||||
public sealed record MapHornPoint(int Index, string Name, Position Position, float Duration, float Tone);
|
||||
|
||||
/// <summary>A station of a timetable; the times in minutes of the day, below 0 none.</summary>
|
||||
public sealed record TimetableRow(string Station, float Km, float Vmax, float Arrival, float Departure, bool Maintenance)
|
||||
{
|
||||
/// <summary>The train stops there: a station passed through has a departure only.</summary>
|
||||
public bool IsStop => Arrival >= 0;
|
||||
}
|
||||
|
||||
/// <summary>The timetable of the train the user was in. The rows are numbered from 1, the station records give their number.</summary>
|
||||
public sealed record Timetable(double Time, int Vehicle, string Train, string Category, string From, string To, float Vmax,
|
||||
IReadOnlyList<TimetableRow> Rows)
|
||||
{
|
||||
public TimetableRow? Row(int number) => number >= 1 && number <= Rows.Count ? Rows[number - 1] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A train at a station of its timetable, at its stop point. Distance: of its head before
|
||||
/// the stop point, below 0 past it; StopAt: where the head was to stop, that many metres before it.
|
||||
/// </summary>
|
||||
public sealed record StationEvent(double Time, int Vehicle, StationKind Kind, int Row, string Station, Position Position,
|
||||
float Distance, float StopAt, float TrainLength, float Platform, float Arrival,
|
||||
float Departure)
|
||||
{
|
||||
/// <summary>How far from the place it was to stop the head stood: above 0 short of it, below 0 past it.</summary>
|
||||
public float StopError => Distance - StopAt;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the whole train stood along the platform, when its length is known: as the driver takes it, the length of
|
||||
/// the trains a W32 is for is the room it has there too.
|
||||
/// </summary>
|
||||
public bool? OnPlatform => Platform != 0 ? -Distance <= PlatformTolerance && Distance + TrainLength - PlatformLength <= PlatformTolerance : null;
|
||||
|
||||
/// <summary>How far off the platform a train may stand at either end and still count as along it [m].</summary>
|
||||
public const double PlatformTolerance = 2;
|
||||
|
||||
/// <summary>The length of the platform [m], without the digit of its side.</summary>
|
||||
public double PlatformLength => Platforms.Length(Platform);
|
||||
|
||||
/// <summary>How much of the train stood off the platform [m]: the head past the stop point, the tail before the platform's end; 0 along it.</summary>
|
||||
public double OffPlatform => Platform != 0 ? Math.Max(0, -Distance) + Math.Max(0, Distance + TrainLength - PlatformLength) : 0;
|
||||
}
|
||||
|
||||
public enum CameraMode : byte
|
||||
{
|
||||
/// <summary>From the cab of the vehicle the user is in.</summary>
|
||||
Cab = 0,
|
||||
/// <summary>One of the outside views of the user's train.</summary>
|
||||
External = 1,
|
||||
/// <summary>The free camera.</summary>
|
||||
Free = 2,
|
||||
Debug = 3
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from. Heading as that of a vehicle: towards (sin, cos) in (x, z).</summary>
|
||||
public readonly record struct CameraState(double Time, CameraMode Mode, int View, int Vehicle, Position Position,
|
||||
float Heading, float Pitch);
|
||||
|
||||
/// <summary>What a coupling joins: the bits of the simulator's coupling.</summary>
|
||||
[Flags]
|
||||
public enum CouplingFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
Coupler = 0x01,
|
||||
BrakePipe = 0x02,
|
||||
Control = 0x04,
|
||||
Power = 0x08,
|
||||
Gangway = 0x10,
|
||||
MainPipe = 0x20,
|
||||
Heating = 0x40,
|
||||
/// <summary>Not undone in shunting: between the cars of a unit.</summary>
|
||||
Permanent = 0x80
|
||||
}
|
||||
|
||||
/// <summary>A coupler coupled, uncoupled, or its hoses and cables changed. Side 0 the front coupler, 1 the rear one.</summary>
|
||||
public sealed record CouplingChange(double Time, int Vehicle, int Side, int Other, int OtherSide, CouplingFlags Flags,
|
||||
CouplingFlags PreviousFlags, int PreviousOther, Position Position)
|
||||
{
|
||||
public bool Coupled => Other != 0 && (Flags & CouplingFlags.Coupler) != 0 && PreviousOther != Other;
|
||||
public bool Uncoupled => Other == 0 && PreviousOther != 0;
|
||||
}
|
||||
|
||||
/// <summary>A train told to sound the horn at a horn point.</summary>
|
||||
public readonly record struct HornRequest(double Time, int Vehicle, float Duration, float Tone, Position Position);
|
||||
|
||||
/// <summary>A vehicle as it is first seen. Id 0 is no vehicle.</summary>
|
||||
public sealed record VehicleInfo(int Id, string Name, string Type, string Train, float Length, float Width,
|
||||
int Category)
|
||||
{
|
||||
public double? RemovedAt { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>What the AI driver would do at that moment, from that place and at that speed.</summary>
|
||||
public readonly record struct AiReference(float TargetKmh, float NextLimitKmh, float NextLimitDistance,
|
||||
float Acceleration, float BrakingDistance, float TimetableVmaxKmh,
|
||||
float TrackVmaxKmh)
|
||||
{
|
||||
public bool HasNextLimit => NextLimitKmh >= 0 && NextLimitDistance >= 0;
|
||||
}
|
||||
|
||||
/// <summary>One look at the user's train, ten a second.</summary>
|
||||
public readonly record struct CabSample(double Time, int Vehicle, Position Position, float SpeedKmh,
|
||||
float Acceleration, float OdometerKm, Driver Driving, CabFlags Flags,
|
||||
sbyte MainController, sbyte ScndController, sbyte Reverser,
|
||||
float TrainBrake, float LocalBrake, float BrakeCylinder, float BrakePipe,
|
||||
float MotorCurrent, AiReference Ai, CabDevices Devices,
|
||||
float MainReservoir, float Voltage, float EngineRpm,
|
||||
Lights LightsHead, Lights LightsTail, sbyte CabEnd = 0,
|
||||
SecurityFlags Security = SecurityFlags.None, float LateralAcceleration = 0,
|
||||
Movement Movement = Movement.Unknown, short LightHintHead = -1, short LightHintTail = -1,
|
||||
Lights? EquippedHead = null, Lights? EquippedTail = null, CabExtra? Extra = null)
|
||||
{
|
||||
public bool Has(CabDevices device) => (Devices & device) == device;
|
||||
public bool Has(SecurityFlags flag) => (Security & flag) == flag;
|
||||
|
||||
public bool AiValid => (Flags & CabFlags.AiValid) != 0;
|
||||
public bool InCab => (Flags & CabFlags.InCab) != 0;
|
||||
public float AbsSpeedKmh => Math.Abs(SpeedKmh);
|
||||
}
|
||||
|
||||
/// <summary>One vehicle in a look at the vehicles.</summary>
|
||||
public readonly record struct VehicleState(int Id, Vector3 Position, float Heading, float SpeedKmh, Driver Driving);
|
||||
|
||||
/// <summary>A look at the vehicles: those which moved, or all of them in a keyframe.</summary>
|
||||
public sealed record VehicleLook(double Time, bool Keyframe, IReadOnlyList<VehicleState> States);
|
||||
|
||||
public readonly record struct SwitchChange(double Time, int Track, int State);
|
||||
|
||||
/// <summary>What a signal shows: the speeds its memory cell holds, 0 stop, -1 no limit, and the command.</summary>
|
||||
public readonly record struct AspectChange(double Time, int Signal, float Speed1, float Speed2, string Command);
|
||||
|
||||
public sealed record CommandEntry(double Time, int Vehicle, bool Pressed, double Param1, double Param2, string Name);
|
||||
|
||||
public readonly record struct DriverChange(double Time, int Vehicle, Driver Driving);
|
||||
|
||||
public sealed record SignalPassing(double Time, int Vehicle, int Signal, string Event, Position Position,
|
||||
float Speed1, float Speed2, float SpeedKmh, float Overrun, PassingFlags Flags)
|
||||
{
|
||||
/// <summary>No signal of the map: the passing came from an event the map doesn't hold.</summary>
|
||||
public const int NoSignal = -1;
|
||||
|
||||
public bool AtDanger => (Flags & PassingFlags.AtStop) != 0;
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the user was in, whether in its cab, and which cab: 1 the front one, -1 the rear one, 0 none.</summary>
|
||||
public readonly record struct UserVehicleChange(double Time, int Vehicle, bool InCab, sbyte CabEnd = 0);
|
||||
|
||||
public readonly record struct ClockEntry(double Time, int Year, int YearDay, double SecondOfDay);
|
||||
13
Eu07.BlackBox/StationNames.cs
Normal file
13
Eu07.BlackBox/StationNames.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>The names of the stations as shown: the scenery tells the stop points of one station apart after a <c>#</c>.</summary>
|
||||
public static class StationNames
|
||||
{
|
||||
/// <summary>The name up to a <c>#</c>, with the underscores of the scenery as spaces.</summary>
|
||||
public static string Display(string name)
|
||||
{
|
||||
int hash = name.IndexOf('#');
|
||||
var shown = hash >= 0 ? name[..hash] : name;
|
||||
return shown.Replace('_', ' ').Trim();
|
||||
}
|
||||
}
|
||||
188
Eu07.BlackBox/TimetableProgress.cs
Normal file
188
Eu07.BlackBox/TimetableProgress.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>How the user's train kept to a station of its timetable.</summary>
|
||||
public enum StationOutcome
|
||||
{
|
||||
/// <summary>Not reached yet in the recording.</summary>
|
||||
Ahead,
|
||||
/// <summary>Stopped where the timetable has a stop.</summary>
|
||||
Stopped,
|
||||
/// <summary>Passed where the timetable has no stop.</summary>
|
||||
Passed,
|
||||
/// <summary>Left a stop behind without stopping, or before the departure.</summary>
|
||||
Missed
|
||||
}
|
||||
|
||||
/// <summary>How late the user's train was at a moment [min, above 0 late], after the station named, standing there or not.</summary>
|
||||
public sealed record TrainDelay(string Train, double Minutes, string Station, bool Standing);
|
||||
|
||||
/// <summary>
|
||||
/// A row of the timetable of the user's train, with what happened there. The times are
|
||||
/// minutes of the day, as in the timetable; the delays are in minutes, above 0 late.
|
||||
/// </summary>
|
||||
public sealed record StationProgress(
|
||||
int Number,
|
||||
TimetableRow Row,
|
||||
StationOutcome Outcome,
|
||||
double? ArrivedAt,
|
||||
double? DepartedAt,
|
||||
double? ArrivalClock,
|
||||
double? DepartureClock,
|
||||
StationEvent? Event)
|
||||
{
|
||||
public double? ArrivalDelay => Delay(ArrivalClock, Row.Arrival);
|
||||
|
||||
/// <summary>Of the departure, or of the pass for a station passed through.</summary>
|
||||
public double? DepartureDelay => Delay(DepartureClock, Row.Departure);
|
||||
|
||||
/// <summary>Above 0 the head stood short of where it was to stop, below 0 past it [m].</summary>
|
||||
public float? StopError => Outcome == StationOutcome.Stopped && Event is { } e ? e.StopError : null;
|
||||
|
||||
public bool? OnPlatform => Outcome == StationOutcome.Stopped ? Event?.OnPlatform : null;
|
||||
|
||||
/// <summary>How far the tail stood off the platform [m], when it stopped.</summary>
|
||||
public double? OffPlatform => Outcome == StationOutcome.Stopped ? Event?.OffPlatform : null;
|
||||
|
||||
/// <summary>The difference of two times of the day in minutes, across midnight the short way.</summary>
|
||||
internal static double? Delay(double? actual, float planned)
|
||||
{
|
||||
if (actual is not { } a || planned < 0)
|
||||
return null;
|
||||
double d = a - planned;
|
||||
if (d > 720) d -= 1440;
|
||||
if (d < -720) d += 1440;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rows of the timetable the user's train had at t, each with what the train did
|
||||
/// there by the end of the recording read so far.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StationProgress> Of(Recording recording, double t) =>
|
||||
recording.TimetableAt(t) is { } timetable ? Of(recording, timetable) : Array.Empty<StationProgress>();
|
||||
|
||||
/// <summary>The rows of a timetable of the recording, each with what the train did there.</summary>
|
||||
public static IReadOnlyList<StationProgress> Of(Recording recording, Timetable timetable) =>
|
||||
recording.Memo((typeof(StationProgress), timetable), () => Work(recording, timetable));
|
||||
|
||||
private static IReadOnlyList<StationProgress> Work(Recording recording, Timetable timetable)
|
||||
{
|
||||
// The records of this train: from its timetable on, until another one is written. A train given its next
|
||||
// timetable may come to the first station of it at once, and a recording made before the simulator wrote the
|
||||
// timetable first has that station up to a look (1 s) before it: it goes with the timetable after.
|
||||
int next = IndexOf(recording.Timetables, timetable) + 1;
|
||||
double until = next < recording.Timetables.Count ? recording.Timetables[next].Time - 1 : double.MaxValue;
|
||||
var events = recording.UserStations()
|
||||
.Where(e => e.Time >= timetable.Time - 1 && e.Time < until)
|
||||
.ToList();
|
||||
|
||||
var rows = new List<StationProgress>(timetable.Rows.Count);
|
||||
for (int number = 1; number <= timetable.Rows.Count; number++)
|
||||
{
|
||||
var row = timetable.Rows[number - 1];
|
||||
var mine = events.Where(e => e.Row == number).ToList();
|
||||
var stopped = mine.FirstOrDefault(e => e.Kind == StationKind.Stopped);
|
||||
var passed = mine.FirstOrDefault(e => e.Kind == StationKind.Passed);
|
||||
var missed = mine.FirstOrDefault(e => e.Kind == StationKind.Missed);
|
||||
if (stopped is not null)
|
||||
{
|
||||
double? departed = DepartureAfter(recording, stopped.Time);
|
||||
rows.Add(new StationProgress(number, row, missed is not null ? StationOutcome.Missed : StationOutcome.Stopped,
|
||||
stopped.Time, departed, Minutes(recording, stopped.Time),
|
||||
departed is { } d ? Minutes(recording, d) : null, stopped));
|
||||
}
|
||||
else if (passed is not null)
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Passed, null, passed.Time, null,
|
||||
Minutes(recording, passed.Time), passed));
|
||||
else if (missed is not null)
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Missed, null, missed.Time, null,
|
||||
Minutes(recording, missed.Time), missed));
|
||||
else
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Ahead, null, null, null, null, null));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>When the user's train got moving again after a stop, null while it still stood.</summary>
|
||||
private static double? DepartureAfter(Recording recording, double stoppedAt)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
for (int i = Math.Max(0, recording.CabIndexAt(stoppedAt)); i < cab.Count; i++)
|
||||
if (cab[i].Time > stoppedAt && cab[i].AbsSpeedKmh > 2)
|
||||
return cab[i].Time;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delay of the user's train at t, from the last station of its timetable it had come to
|
||||
/// by then: of the departure or the pass once it left, of the arrival while it stands, growing
|
||||
/// while it stands past the departure, and growing on the way once it is past the time it was
|
||||
/// to be at the next station. Null before the first station or without a timetable.
|
||||
/// </summary>
|
||||
public static TrainDelay? DelayAt(Recording recording, double t)
|
||||
{
|
||||
if (recording.TimetableAt(t) is not { } timetable || t < timetable.Time - 1)
|
||||
return null;
|
||||
double clock = recording.ClockAt(t).TotalMinutes;
|
||||
TrainDelay? delay = null;
|
||||
var rows = Of(recording, timetable);
|
||||
int reached = -1;
|
||||
for (int i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var row = rows[i];
|
||||
if (row.DepartedAt is { } departed && departed <= t)
|
||||
delay = new TrainDelay(timetable.Train, row.DepartureDelay ?? row.ArrivalDelay ?? delay?.Minutes ?? 0, row.Row.Station, false);
|
||||
else if (row.ArrivedAt is { } arrived && arrived <= t)
|
||||
{
|
||||
double minutes = row.ArrivalDelay ?? delay?.Minutes ?? 0;
|
||||
if (Delay(clock, row.Row.Departure) is { } overdue)
|
||||
minutes = Math.Max(minutes, overdue);
|
||||
delay = new TrainDelay(timetable.Train, minutes, row.Row.Station, true);
|
||||
}
|
||||
else
|
||||
continue;
|
||||
reached = i;
|
||||
}
|
||||
// On the way, past the time it was to be at the next station: at least that late, and more as it stays away.
|
||||
if (delay is { Standing: false } && reached + 1 < rows.Count)
|
||||
{
|
||||
var next = rows[reached + 1].Row;
|
||||
if (Delay(clock, next.Arrival >= 0 ? next.Arrival : next.Departure) is { } overdue && overdue > delay.Minutes)
|
||||
delay = delay with { Minutes = overdue };
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
/// <summary>The delays the user's train had at the stations it had left or stood at by t, by the name shown.</summary>
|
||||
public static IReadOnlyDictionary<string, double> StationDelays(Recording recording, double t)
|
||||
{
|
||||
var delays = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var timetable in recording.Timetables)
|
||||
{
|
||||
if (timetable.Time > t)
|
||||
break;
|
||||
foreach (var row in Of(recording, timetable))
|
||||
{
|
||||
double? at = row.ArrivedAt ?? row.DepartedAt;
|
||||
double? delay = row.DepartedAt <= t ? row.DepartureDelay ?? row.ArrivalDelay : row.ArrivalDelay;
|
||||
if (at <= t && delay is { } d)
|
||||
delays[StationNames.Display(row.Row.Station)] = d;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
}
|
||||
|
||||
private static int IndexOf(IReadOnlyList<Timetable> timetables, Timetable timetable)
|
||||
{
|
||||
for (int i = 0; i < timetables.Count; i++)
|
||||
if (ReferenceEquals(timetables[i], timetable))
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static double Minutes(Recording recording, double t) => recording.ClockAt(t).TotalMinutes;
|
||||
}
|
||||
397
StarterNG.Tests/BlackBox/BlackBoxFile.cs
Normal file
397
StarterNG.Tests/BlackBox/BlackBoxFile.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a black box the way the simulator does (blackbox_doc.md of its repository),
|
||||
/// field by field, so the reader is checked against the layout and not against itself.
|
||||
/// </summary>
|
||||
internal sealed class BlackBoxFile
|
||||
{
|
||||
private readonly MemoryStream _bytes = new();
|
||||
private readonly BinaryWriter _file;
|
||||
|
||||
public BlackBoxFile(ushort version = 1, bool header = true)
|
||||
{
|
||||
_file = new BinaryWriter(_bytes, Encoding.UTF8, leaveOpen: true);
|
||||
if (header)
|
||||
{
|
||||
_file.Write("EU07BBX\0"u8);
|
||||
_file.Write(version);
|
||||
_file.Write((ushort)0);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Bytes => _bytes.ToArray();
|
||||
|
||||
public MemoryStream Stream() => new(Bytes);
|
||||
|
||||
/// <summary>A record of a type, its length and what the writer puts in it.</summary>
|
||||
public BlackBoxFile Record(byte type, Action<BinaryWriter> payload)
|
||||
{
|
||||
using var body = new MemoryStream();
|
||||
using (var w = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true))
|
||||
payload(w);
|
||||
_file.Write(type);
|
||||
_file.Write((uint)body.Length);
|
||||
_file.Write(body.ToArray());
|
||||
return this;
|
||||
}
|
||||
|
||||
public BlackBoxFile Info(double secondOfDay = 10 * 3600 + 34 * 60, string scenery = "td.scn") =>
|
||||
Record(0x01, w =>
|
||||
{
|
||||
w.Write(scenery);
|
||||
w.Write("2026-11-08T22:56:30");
|
||||
w.Write((ushort)2026);
|
||||
w.Write((ushort)312);
|
||||
w.Write(secondOfDay);
|
||||
w.Write("ep07-bb");
|
||||
w.Write("EU07 test");
|
||||
w.Write(0.1f);
|
||||
w.Write(1.0f);
|
||||
w.Write(60.0f);
|
||||
});
|
||||
|
||||
public BlackBoxFile Track(uint index, string name, params Vector3[] points) =>
|
||||
Track(index, name, 0, 0, points);
|
||||
|
||||
public BlackBoxFile Track(uint index, string name, byte kind, byte branch, params Vector3[] points) =>
|
||||
Record(0x10, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(kind);
|
||||
w.Write(branch);
|
||||
w.Write((byte)1);
|
||||
w.Write((uint)points.Length);
|
||||
foreach (var p in points)
|
||||
{
|
||||
w.Write(p.X);
|
||||
w.Write(p.Y);
|
||||
w.Write(p.Z);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Signal(uint index, string name, double x, double y, double z) =>
|
||||
Record(0x11, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(x);
|
||||
w.Write(y);
|
||||
w.Write(z);
|
||||
});
|
||||
|
||||
public BlackBoxFile Vehicle(uint id, string name, string train = "", float length = 16.3f) =>
|
||||
Record(0x12, w =>
|
||||
{
|
||||
w.Write(id);
|
||||
w.Write(name);
|
||||
w.Write("303e-ep-tv");
|
||||
w.Write(train);
|
||||
w.Write(length);
|
||||
w.Write(2.9f);
|
||||
w.Write((byte)1);
|
||||
});
|
||||
|
||||
public BlackBoxFile Removed(double time, uint id) =>
|
||||
Record(0x13, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(id);
|
||||
});
|
||||
|
||||
public BlackBoxFile Cab(double time, uint vehicle, double z, float speed, byte driving = 1, byte flags = 0x03,
|
||||
float target = 40, float nextLimit = -1, float nextDistance = -1, float brakeCylinder = 0,
|
||||
uint devices = 0x0007, bool extended = true, sbyte cabEnd = 1, byte security = 0,
|
||||
float lateral = 0.2f, ushort lightsHead = 0x15, ushort lightsTail = 0x22, byte? movement = null,
|
||||
short hintHead = -1, short hintTail = -1, ushort? equippedHead = null, ushort? equippedTail = null,
|
||||
byte? engine = null, float oilTemperature = 70) =>
|
||||
Record(0x20, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(speed);
|
||||
w.Write(0.1f); // acceleration
|
||||
w.Write(1.5f); // odometer
|
||||
w.Write(driving);
|
||||
w.Write(flags);
|
||||
w.Write((sbyte)3); // main controller
|
||||
w.Write((sbyte)0);
|
||||
w.Write((sbyte)1);
|
||||
w.Write(0.0f); // train brake
|
||||
w.Write(0.0f);
|
||||
w.Write(brakeCylinder);
|
||||
w.Write(5.0f); // brake pipe
|
||||
w.Write(320.0f); // current
|
||||
w.Write(target);
|
||||
w.Write(nextLimit);
|
||||
w.Write(nextDistance);
|
||||
w.Write(0.3f);
|
||||
w.Write(150.0f);
|
||||
w.Write(60.0f);
|
||||
w.Write(-1.0f);
|
||||
w.Write(devices);
|
||||
w.Write(8.5f); // main reservoir
|
||||
w.Write(3000.0f); // voltage
|
||||
w.Write(1100.0f); // revolutions
|
||||
w.Write(lightsHead); // lights at the head of the train
|
||||
w.Write(lightsTail);
|
||||
// The cab, the train protection and the lateral acceleration: a recording made before them ends above.
|
||||
if (extended)
|
||||
{
|
||||
w.Write(cabEnd);
|
||||
w.Write(security);
|
||||
w.Write(lateral);
|
||||
// what the driver goes by and the lights asked for: a recording made before them ends above
|
||||
if (movement is { } kind)
|
||||
{
|
||||
w.Write(kind);
|
||||
w.Write(hintHead);
|
||||
w.Write(hintTail);
|
||||
// the lamps the vehicles at the ends have: a recording made before them ends above
|
||||
if (equippedHead is { } head)
|
||||
{
|
||||
w.Write(head);
|
||||
w.Write(equippedTail ?? head);
|
||||
// what drives it and the rest: a recording made before them ends above
|
||||
if (engine is { } kind2)
|
||||
{
|
||||
w.Write(kind2);
|
||||
w.Write((byte)2); // brake setting P
|
||||
w.Write((byte)3); // radio channel
|
||||
w.Write(4.8f); // equalizing reservoir
|
||||
w.Write(8.0f); // main reservoir pipe
|
||||
w.Write(0.45f); // oil pressure
|
||||
w.Write(75.0f); // water
|
||||
w.Write(oilTemperature);
|
||||
w.Write(80.0f); // engine
|
||||
w.Write(0.0f); // cruise control
|
||||
w.Write(900.0f); // power
|
||||
w.Write((sbyte)0); // hand brake
|
||||
w.Write((byte)1); // brake mode
|
||||
w.Write((byte)0); // horn
|
||||
w.Write(0.5f); // integrated controller
|
||||
w.Write(1.0f); // clutch
|
||||
w.Write(0.0f); // torque converter
|
||||
w.Write(2.5f); // the highest brake cylinder of the train
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Vehicles(double time, bool keyframe, params (uint Id, float X, float Z, float Speed)[] states) =>
|
||||
Record(0x21, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write((byte)(keyframe ? 1 : 0));
|
||||
w.Write((uint)states.Length);
|
||||
foreach (var s in states)
|
||||
{
|
||||
w.Write(s.Id);
|
||||
w.Write(s.X);
|
||||
w.Write(0.4f);
|
||||
w.Write(s.Z);
|
||||
w.Write(0.0f); // heading
|
||||
w.Write(s.Speed);
|
||||
w.Write((byte)2);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Switch(double time, uint track, byte state) =>
|
||||
Record(0x22, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(track);
|
||||
w.Write(state);
|
||||
});
|
||||
|
||||
public BlackBoxFile Aspect(double time, uint signal, float speed1, float speed2, string command = "SetVelocity") =>
|
||||
Record(0x23, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(signal);
|
||||
w.Write(speed1);
|
||||
w.Write(speed2);
|
||||
w.Write(command);
|
||||
});
|
||||
|
||||
public BlackBoxFile Command(double time, uint vehicle, bool pressed, string name) =>
|
||||
Record(0x30, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write((byte)(pressed ? 1 : 0));
|
||||
w.Write(1.0);
|
||||
w.Write(2.0);
|
||||
w.Write(name);
|
||||
});
|
||||
|
||||
public BlackBoxFile Driver(double time, uint vehicle, byte driving) =>
|
||||
Record(0x31, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(driving);
|
||||
});
|
||||
|
||||
public BlackBoxFile Passed(double time, uint vehicle, uint signal, string name, float speed1, float speed,
|
||||
byte flags = 0, double z = 1300) =>
|
||||
Record(0x32, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(signal);
|
||||
w.Write(name);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(speed1);
|
||||
w.Write(-1.0f);
|
||||
w.Write(speed);
|
||||
w.Write(2.5f);
|
||||
w.Write(flags);
|
||||
});
|
||||
|
||||
public BlackBoxFile User(double time, uint vehicle, bool inCab, sbyte? cabEnd = null) =>
|
||||
Record(0x33, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write((byte)(inCab ? 1 : 0));
|
||||
if (cabEnd is { } end)
|
||||
w.Write(end);
|
||||
});
|
||||
|
||||
public BlackBoxFile StopPoint(uint index, string station, double z, float stopAt, float platform, float? heading = null) =>
|
||||
Record(0x14, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(station);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(stopAt);
|
||||
w.Write(platform);
|
||||
if (heading is { } way)
|
||||
w.Write(way);
|
||||
});
|
||||
|
||||
public BlackBoxFile HornPoint(uint index, string name, double z, float duration = 2, float tone = 1) =>
|
||||
Record(0x15, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(duration);
|
||||
w.Write(tone);
|
||||
});
|
||||
|
||||
public BlackBoxFile Timetable(double time, uint vehicle, string train, string from, string to,
|
||||
params (string Station, float Arrival, float Departure)[] rows) =>
|
||||
Record(0x24, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(train);
|
||||
w.Write("ROJ");
|
||||
w.Write(from);
|
||||
w.Write(to);
|
||||
w.Write(100.0f);
|
||||
w.Write((uint)rows.Length);
|
||||
float km = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
w.Write(row.Station);
|
||||
w.Write(km += 2.5f);
|
||||
w.Write(100.0f);
|
||||
w.Write(row.Arrival);
|
||||
w.Write(row.Departure);
|
||||
w.Write((byte)0);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Station(double time, uint vehicle, byte kind, ushort row, string station, double z,
|
||||
float distance = 1, float stopAt = 0, float trainLength = 87, float platform = 150,
|
||||
float arrival = -1, float departure = -1) =>
|
||||
Record(0x35, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(kind);
|
||||
w.Write(row);
|
||||
w.Write(station);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(distance);
|
||||
w.Write(stopAt);
|
||||
w.Write(trainLength);
|
||||
w.Write(platform);
|
||||
w.Write(arrival);
|
||||
w.Write(departure);
|
||||
});
|
||||
|
||||
public BlackBoxFile HornRequest(double time, uint vehicle, double z, float duration = 2) =>
|
||||
Record(0x36, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(duration);
|
||||
w.Write(1.0f);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
});
|
||||
|
||||
public BlackBoxFile Camera(double time, byte mode, uint vehicle, double z, byte view = 0, float heading = 0.5f) =>
|
||||
Record(0x25, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(mode);
|
||||
w.Write(view);
|
||||
w.Write(vehicle);
|
||||
w.Write(5.0);
|
||||
w.Write(2.4);
|
||||
w.Write(z);
|
||||
w.Write(heading);
|
||||
w.Write(-0.1f);
|
||||
});
|
||||
|
||||
public BlackBoxFile Coupling(double time, uint vehicle, byte side, uint other, byte otherSide, byte flags,
|
||||
byte previousFlags, uint previousOther) =>
|
||||
Record(0x37, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(side);
|
||||
w.Write(other);
|
||||
w.Write(otherSide);
|
||||
w.Write(flags);
|
||||
w.Write(previousFlags);
|
||||
w.Write(previousOther);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(1150.0);
|
||||
});
|
||||
|
||||
public BlackBoxFile Clock(double time, double secondOfDay) =>
|
||||
Record(0x34, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write((ushort)2026);
|
||||
w.Write((ushort)312);
|
||||
w.Write(secondOfDay);
|
||||
});
|
||||
|
||||
public BlackBoxFile End(double time) => Record(0x3f, w => w.Write(time));
|
||||
}
|
||||
253
StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
Normal file
253
StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>
|
||||
/// A journey of two timetables one after another: the stations, the horn points, the train
|
||||
/// protection, the camera and the couplings, as the simulator writes them, and what the
|
||||
/// recording tells of them.
|
||||
/// </summary>
|
||||
public class BlackBoxJourneyTests
|
||||
{
|
||||
private const uint Head = 1, Car = 2, Tail = 3, Stranger = 9;
|
||||
private const byte Coupled = 0x83;
|
||||
|
||||
/// <summary>
|
||||
/// The user in vehicle 1 of a unit 1-2-3, the clock at 10:34 (634 min). ROJ1 stands at A, stops at B
|
||||
/// at t = 60 until 70, passes C at 100 without stopping; ROJ2 from t = 110 stops at D at 115.
|
||||
/// </summary>
|
||||
private static Recording Journey()
|
||||
{
|
||||
var f = new BlackBoxFile().Info()
|
||||
.StopPoint(0, "Stacja_B#1", 1300, 10, 150)
|
||||
.StopPoint(1, "Stacja_B#2", 1310, 10, 150)
|
||||
.HornPoint(0, "w6_1", 1200)
|
||||
.User(0, Head, true, -1)
|
||||
.Coupling(0, Head, 1, Car, 0, Coupled, 0, 0)
|
||||
.Coupling(0, Car, 1, Tail, 0, Coupled, 0, 0)
|
||||
.Camera(0, 0, Head, 1150)
|
||||
.Timetable(0, Head, "ROJ1", "A", "C", ("A", -1, 634), ("Stacja_B", 635, 635.5f), ("C", 637.5f, 638))
|
||||
.Station(1, Head, 4, 1, "A", 1150);
|
||||
for (double t = 0; t <= 130; t += 0.5)
|
||||
{
|
||||
float speed = t is >= 55 and < 70 || t >= 112 && t < 120 ? 0 : 40;
|
||||
uint devices = 0x0007 | (t is >= 22 and < 24 ? 0x0200u : 0);
|
||||
byte security = (byte)(t is >= 30 and < 32 ? 0x11 : 0x01);
|
||||
f.Cab(t, Head, 1150 + t * 10, speed, devices: devices, security: security, lateral: t is >= 45 and < 48 ? 1.2f : 0.1f);
|
||||
}
|
||||
f.HornRequest(20, Head, 1200)
|
||||
.HornRequest(40, Head, 1550)
|
||||
.Camera(50, 2, 0, 1650)
|
||||
.Station(60, Head, 1, 2, "Stacja_B#2", 1300)
|
||||
.Station(60, Stranger, 1, 2, "Stacja_B#1", 9000)
|
||||
.Coupling(80, Car, 1, 0, 0, 0, Coupled, Tail)
|
||||
.Station(100, Head, 3, 3, "C", 2150)
|
||||
.Timetable(110, Head, "ROJ2", "C", "D", ("C", -1, 638), ("D", 640, -1))
|
||||
.Station(115, Head, 1, 2, "D", 2300);
|
||||
return BlackBoxReader.Read(f.End(130).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_new_records_are_read_field_by_field()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal(0, r.Unknown);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
Assert.Equal(2, r.StopPoints.Count);
|
||||
Assert.Equal("Stacja_B#2", r.StopPoints[1].Station);
|
||||
Assert.Equal(10f, r.StopPoints[1].StopAt);
|
||||
Assert.Equal(150f, r.StopPoints[1].Platform);
|
||||
Assert.Equal("w6_1", Assert.Single(r.HornPoints).Name);
|
||||
Assert.Equal(2, r.Timetables.Count);
|
||||
Assert.Equal("ROJ2", r.Timetables[1].Train);
|
||||
Assert.Equal(640f, r.Timetables[1].Row(2)!.Arrival);
|
||||
Assert.False(r.Timetables[1].Row(2)!.Maintenance);
|
||||
Assert.Equal(5, r.Stations.Count);
|
||||
Assert.Equal(StationKind.Missed, r.Stations.Single(s => s.Row == 3 && s.Vehicle == Head).Kind);
|
||||
Assert.Equal(2, r.HornRequests.Count);
|
||||
Assert.Equal(2, r.Cameras.Count);
|
||||
Assert.Equal(CameraMode.Free, r.Cameras[1].Mode);
|
||||
Assert.Equal(-0.1f, r.Cameras[1].Pitch);
|
||||
Assert.Equal(3, r.Couplings.Count);
|
||||
Assert.True(r.Couplings[2].Uncoupled);
|
||||
Assert.Equal(-1, Assert.Single(r.UserVehicles).CabEnd);
|
||||
|
||||
var cab = r.Cab[0];
|
||||
Assert.Equal(1, cab.CabEnd);
|
||||
Assert.Equal(SecurityFlags.Vigilance, cab.Security);
|
||||
Assert.Equal(0.1f, cab.LateralAcceleration);
|
||||
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cab_written_before_the_protection_and_the_lateral_acceleration_is_still_read()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile().Info().Cab(0, Head, 1150, 40, extended: false).User(0, Head, true).End(1).Stream());
|
||||
|
||||
var cab = Assert.Single(r.Cab);
|
||||
Assert.Equal(0, cab.CabEnd);
|
||||
Assert.Equal(SecurityFlags.None, cab.Security);
|
||||
Assert.Equal(0f, cab.LateralAcceleration);
|
||||
Assert.Equal(0, Assert.Single(r.UserVehicles).CabEnd);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_couplings_tell_the_train_at_any_moment()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal(new HashSet<int> { 1, 2, 3 }, r.ConsistAt((int)Head, 79));
|
||||
Assert.Equal(new HashSet<int> { 1, 2 }, r.ConsistAt((int)Head, 81));
|
||||
Assert.Equal(new HashSet<int> { 3 }, r.ConsistAt((int)Tail, 81));
|
||||
// What the trains were made of at the start is no coupling done.
|
||||
var change = Assert.Single(r.CouplingEvents());
|
||||
Assert.Equal(80, change.Time);
|
||||
Assert.Single(r.UserCouplings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_stations_of_someone_else_s_train_are_not_the_user_s()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.DoesNotContain(r.UserStations(), s => s.Vehicle == Stranger);
|
||||
Assert.Equal(4, r.UserStations().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_timetable_shown_is_the_one_the_train_had_then()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(100)!.Train);
|
||||
Assert.Equal("ROJ2", r.TimetableAt(110)!.Train);
|
||||
|
||||
var first = StationProgress.Of(r, 50);
|
||||
Assert.Equal(3, first.Count);
|
||||
Assert.Equal(StationOutcome.Stopped, first[1].Outcome);
|
||||
Assert.Equal(60, first[1].ArrivedAt);
|
||||
Assert.Equal(70, first[1].DepartedAt);
|
||||
Assert.Equal(0, first[1].ArrivalDelay!.Value, 2);
|
||||
Assert.Equal(-1 / 3.0, first[1].DepartureDelay!.Value, 2);
|
||||
Assert.Equal(StationOutcome.Missed, first[2].Outcome);
|
||||
Assert.Equal(StationOutcome.Stopped, StationProgress.Of(r, 120)[1].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_delay_follows_the_train_from_station_to_station()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Null(StationProgress.DelayAt(r, 0.5));
|
||||
var standing = StationProgress.DelayAt(r, 65)!;
|
||||
Assert.True(standing.Standing);
|
||||
Assert.Equal("Stacja_B", standing.Station);
|
||||
Assert.Equal(0, standing.Minutes, 2);
|
||||
var left = StationProgress.DelayAt(r, 80)!;
|
||||
Assert.False(left.Standing);
|
||||
Assert.Equal(-1 / 3.0, left.Minutes, 2);
|
||||
// At D, arrived at 10:35:55 for 10:40: early, and nothing to depart by.
|
||||
var second = StationProgress.DelayAt(r, 118)!;
|
||||
Assert.Equal("ROJ2", second.Train);
|
||||
Assert.True(second.Minutes < -4);
|
||||
|
||||
// Left B at 10:35:10, C planned 10:37:30: by 10:35:50 (t = 110 of ROJ1's time) not yet overdue.
|
||||
Assert.Equal(-1 / 3.0, StationProgress.DelayAt(r, 90)!.Minutes, 2);
|
||||
|
||||
var delays = StationProgress.StationDelays(r, 80);
|
||||
Assert.Equal(-1 / 3.0, delays["Stacja B"], 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_names_of_the_stations_are_shown_without_what_tells_their_stop_points_apart()
|
||||
{
|
||||
Assert.Equal("Bałtyk Główny", StationNames.Display("Bałtyk_Główny#2"));
|
||||
Assert.Equal("Turów", StationNames.Display("Turów"));
|
||||
Assert.Equal("Psie Pole po", StationNames.Display("Psie_Pole_po#"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_horn_counts_when_it_sounded_after_the_horn_point()
|
||||
{
|
||||
var checks = Journey().UserHornChecks().ToList();
|
||||
|
||||
Assert.Equal(2, checks.Count);
|
||||
Assert.True(checks[0].Sounded);
|
||||
Assert.False(checks[1].Sounded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_camera_is_known_from_its_first_record()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Null(r.CameraAt(-1));
|
||||
Assert.Equal(CameraMode.Cab, r.CameraAt(49)!.Value.Mode);
|
||||
Assert.Equal(CameraMode.Free, r.CameraAt(51)!.Value.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_summary_counts_the_stops_of_every_timetable_the_horns_and_the_protection()
|
||||
{
|
||||
var summary = Journey().Summarize();
|
||||
|
||||
Assert.Equal(2, summary.Stops);
|
||||
Assert.Equal(1, summary.StopsMissed);
|
||||
Assert.Equal(2, summary.HornPoints);
|
||||
Assert.Equal(1, summary.HornsMissed);
|
||||
Assert.Equal(1, summary.SecurityBrakings);
|
||||
// Taken over half a second, as the lateral acceleration jumps from sample to sample.
|
||||
Assert.Equal(1.2f, summary.MaxLateralAcceleration, 3);
|
||||
}
|
||||
}
|
||||
|
||||
public class TrainDelayTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_train_held_on_the_way_grows_late_by_the_time_of_the_next_station()
|
||||
{
|
||||
// Clock 10:34 at t = 0. Departs A at 10:35 (t = 60) on time, held short of B planned at 10:37.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ9", "A", "B", ("A", -1, 635), ("B", 637, 638))
|
||||
.Station(1, 1, 1, 1, "A", 1150);
|
||||
for (double t = 0; t <= 400; t += 0.5)
|
||||
f.Cab(t, 1, 1150 + Math.Max(0, Math.Min(t, 90) - 60) * 10, t is > 60 and < 90 ? 40 : 0);
|
||||
var r = BlackBoxReader.Read(f.End(400).Stream());
|
||||
|
||||
Assert.Equal(0, StationProgress.DelayAt(r, 100)!.Minutes, 1);
|
||||
// 10:40 at t = 360: three minutes past B's arrival, and not there.
|
||||
var held = StationProgress.DelayAt(r, 360)!;
|
||||
Assert.False(held.Standing);
|
||||
Assert.Equal(3, held.Minutes, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A train given its next timetable, one recording holding both.</summary>
|
||||
public class NextTimetableTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_station_written_just_before_the_next_timetable_goes_with_it()
|
||||
{
|
||||
// ROJ1 stops at A and ends at B; at B it is given ROJ2, which starts there. A recording made before
|
||||
// the simulator wrote the timetable first has B of ROJ2 half a second ahead of ROJ2 itself (here left behind,
|
||||
// which on ROJ1 would mark A missed).
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 637, -1))
|
||||
.Station(1, 1, 1, 1, "A", 1150)
|
||||
.Station(50, 1, 1, 2, "B", 1150)
|
||||
.Station(99.5, 1, 3, 1, "B", 1150)
|
||||
.Timetable(100, 1, "ROJ2", "B", "C", ("B", -1, 640), ("C", 645, -1));
|
||||
for (double t = 0; t <= 120; t += 0.5)
|
||||
f.Cab(t, 1, 1150, 0);
|
||||
var r = BlackBoxReader.Read(f.End(120).Stream());
|
||||
|
||||
var first = StationProgress.Of(r, r.Timetables[0]);
|
||||
Assert.Equal(StationOutcome.Stopped, first[0].Outcome);
|
||||
Assert.Equal(1, first[0].Event!.Time);
|
||||
var second = StationProgress.Of(r, r.Timetables[1]);
|
||||
Assert.Equal(StationOutcome.Missed, second[0].Outcome);
|
||||
Assert.Equal(99.5, second[0].Event!.Time);
|
||||
}
|
||||
}
|
||||
96
StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
Normal file
96
StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class BlackBoxListingTests
|
||||
{
|
||||
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static string Log(string name) => TestInstallation.At("physicslog", name);
|
||||
|
||||
[Fact]
|
||||
public void Each_run_keeps_its_own_black_box_next_to_its_tapes()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Log("ra.dat"), "t")
|
||||
.WithFile(Log("td_2025-12-31_23-00-05.blackbox"), "b")
|
||||
.WithFile(Log("td_2026-01-01_00-00-05.blackbox"), "b");
|
||||
var installation = new TestInstallation(files);
|
||||
var tapes = installation.Tapes;
|
||||
installation.Clock.Now = Launched;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A");
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "B");
|
||||
files.WrittenAt(Log("td_2025-12-31_23-00-05.blackbox"), Launched.AddMinutes(30))
|
||||
.WrittenAt(Log("td_2026-01-01_00-00-05.blackbox"), Launched.AddMinutes(90))
|
||||
.WrittenAt(Log("ra.dat"), Launched.AddMinutes(90));
|
||||
|
||||
var sessions = tapes.Sessions();
|
||||
|
||||
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
|
||||
Assert.EndsWith("00-00-05.blackbox", Assert.Single(sessions[0].BlackBoxes).Path);
|
||||
Assert.Single(sessions[0].Tapes);
|
||||
// The tape of the first run was written over by the second; its black box was not.
|
||||
Assert.EndsWith("23-00-05.blackbox", Assert.Single(sessions[1].BlackBoxes).Path);
|
||||
Assert.Empty(sessions[1].Tapes);
|
||||
Assert.Equal(Launched.AddMinutes(30), sessions[1].LastWrittenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_black_box_is_not_taken_for_a_tape()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Log("ra.dat"), "t")
|
||||
.WithFile(Log("td_2026-03-01_12-00-00.blackbox"), "b"));
|
||||
|
||||
var session = Assert.Single(installation.Tapes.Sessions());
|
||||
|
||||
Assert.Equal("ra", Assert.Single(session.Tapes).Vehicle);
|
||||
Assert.Single(session.BlackBoxes);
|
||||
Assert.Equal("ra", Assert.Single(installation.Tapes.List()).Vehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Black_boxes_with_no_launch_noted_go_with_the_tapes_written_about_the_same_time()
|
||||
{
|
||||
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Log("a.dat"), "t")
|
||||
.WithFile(Log("td_2026-03-01_11-00-00.blackbox"), "b")
|
||||
.WithFile(Log("td_2026-03-01_12-30-00.blackbox"), "b")
|
||||
.WrittenAt(Log("a.dat"), early)
|
||||
.WrittenAt(Log("td_2026-03-01_11-00-00.blackbox"), early.AddSeconds(2))
|
||||
.WrittenAt(Log("td_2026-03-01_12-30-00.blackbox"), early.AddHours(1)));
|
||||
|
||||
var sessions = installation.Tapes.Sessions();
|
||||
|
||||
Assert.Equal(2, sessions.Count);
|
||||
Assert.All(sessions, session => Assert.Null(session.Launch));
|
||||
// Newest first: the black box on its own, then the run with the tape.
|
||||
Assert.Empty(sessions[0].Tapes);
|
||||
Assert.EndsWith("12-30-00.blackbox", Assert.Single(sessions[0].BlackBoxes).Path);
|
||||
Assert.Single(sessions[1].Tapes);
|
||||
Assert.EndsWith("11-00-00.blackbox", Assert.Single(sessions[1].BlackBoxes).Path);
|
||||
Assert.Equal(early, sessions[1].StartedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_black_box_of_a_running_session_is_listed_while_it_is_written()
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
|
||||
files.WithFile(Log("td_2025-12-31_23-00-20.blackbox"), "b")
|
||||
.WrittenAt(Log("td_2025-12-31_23-00-20.blackbox"), Launched.AddMinutes(5));
|
||||
|
||||
installation.SimulatorRunning = true;
|
||||
var running = Assert.Single(tapes.Sessions());
|
||||
|
||||
Assert.True(running.Running);
|
||||
Assert.Empty(running.Tapes);
|
||||
Assert.Single(running.BlackBoxes);
|
||||
}
|
||||
}
|
||||
229
StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
Normal file
229
StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
using System.Numerics;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class BlackBoxReaderTests
|
||||
{
|
||||
private static BlackBoxFile Session() => new BlackBoxFile()
|
||||
.Info()
|
||||
.Track(0, "start", new Vector3(5, 0.4f, 1100), new Vector3(5, 0.4f, 1200))
|
||||
.Track(1, "zwr01", 1, 0, new Vector3(5, 0.4f, 1200), new Vector3(5, 0.4f, 1234))
|
||||
.Track(1, "zwr01", 1, 1, new Vector3(5, 0.4f, 1200), new Vector3(8, 0.4f, 1234))
|
||||
.Signal(0, "stacja1_a", 2.5, 3.0, 1300)
|
||||
.Vehicle(1, "ep07-bb", "ROJ65113")
|
||||
.Vehicle(2, "bdnu")
|
||||
.Switch(0, 1, 0)
|
||||
.Aspect(0, 0, 0, -1)
|
||||
.Clock(0, 10 * 3600 + 34 * 60)
|
||||
.User(0, 1, true)
|
||||
.Vehicles(0, true, (1, 5, 1150, 0), (2, 5, 1130, 0))
|
||||
.Cab(0.1, 1, 1150, 0)
|
||||
.Command(1.0, 1, true, "aidriverenable")
|
||||
.Command(1.1, 1, false, "aidriverenable")
|
||||
.Driver(1.0, 1, 2)
|
||||
.Aspect(2.0, 0, 40, -1)
|
||||
.Switch(3.0, 1, 1)
|
||||
.Passed(4.0, 1, 0, "stacja1_a_sem", 40, 38.5f, 0x01)
|
||||
.Removed(5.0, 2)
|
||||
.End(6.0);
|
||||
|
||||
[Fact]
|
||||
public void Every_record_of_a_session_is_read_as_the_simulator_wrote_it()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(1, r.Version);
|
||||
Assert.Equal("td.scn", r.Info!.Scenery);
|
||||
Assert.Equal(new TimeSpan(10, 34, 0), r.Info.StartClock);
|
||||
Assert.Equal(60f, r.Info.KeyframeInterval);
|
||||
Assert.Equal(3, r.Tracks.Count);
|
||||
Assert.Equal(2, r.TrackWays(1).Count);
|
||||
Assert.Equal(TrackKind.SwitchBranch, r.TrackWays(1)[1].Kind);
|
||||
Assert.Equal(new Vector3(8, 0.4f, 1234), r.TrackWays(1)[1].Points[1]);
|
||||
Assert.Equal("stacja1_a", r.Signal(0)!.Name);
|
||||
Assert.Equal(1300, r.Signal(0)!.Position.Z);
|
||||
Assert.Equal("ROJ65113", r.Vehicle(1)!.Train);
|
||||
Assert.Equal(5.0, r.Vehicle(2)!.RemovedAt);
|
||||
|
||||
var cab = Assert.Single(r.Cab);
|
||||
Assert.Equal(1, cab.Vehicle);
|
||||
Assert.Equal(new Position(5, 0.4, 1150), cab.Position);
|
||||
Assert.Equal(Driver.Human, cab.Driving);
|
||||
Assert.True(cab.AiValid);
|
||||
Assert.Equal(3, cab.MainController);
|
||||
Assert.Equal(5.0f, cab.BrakePipe);
|
||||
Assert.Equal(40f, cab.Ai.TargetKmh);
|
||||
Assert.Equal(60f, cab.Ai.TimetableVmaxKmh);
|
||||
Assert.False(cab.Ai.HasNextLimit);
|
||||
Assert.Equal(CabDevices.Battery | CabDevices.MainBreaker | CabDevices.PantographFront, cab.Devices);
|
||||
Assert.True(cab.Has(CabDevices.MainBreaker));
|
||||
Assert.False(cab.Has(CabDevices.Compressor));
|
||||
Assert.Equal(8.5f, cab.MainReservoir);
|
||||
Assert.Equal(3000f, cab.Voltage);
|
||||
Assert.Equal(1100f, cab.EngineRpm);
|
||||
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
|
||||
Assert.Equal(Lights.RedMarkerLeft | Lights.RedMarkerRight, cab.LightsTail);
|
||||
|
||||
Assert.Equal(2, r.Commands.Count);
|
||||
Assert.Equal("aidriverenable", r.Commands[0].Name);
|
||||
Assert.True(r.Commands[0].Pressed);
|
||||
Assert.Equal(2.0, r.Commands[0].Param2);
|
||||
Assert.Equal(Driver.Ai, Assert.Single(r.DriverChanges).Driving);
|
||||
var passed = Assert.Single(r.Passings);
|
||||
Assert.Equal("stacja1_a_sem", passed.Event);
|
||||
Assert.Equal(PassingFlags.Shunt, passed.Flags);
|
||||
Assert.Equal(38.5f, passed.SpeedKmh);
|
||||
Assert.False(passed.AtDanger);
|
||||
Assert.True(r.Complete);
|
||||
Assert.Equal(6.0, r.Duration);
|
||||
Assert.Equal(0, r.Unknown);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_switches_and_the_aspects_answer_for_any_moment()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(0, r.SwitchAt(1, 2.9));
|
||||
Assert.Equal(1, r.SwitchAt(1, 3.0));
|
||||
Assert.Equal(-1, r.SwitchAt(7, 3.0));
|
||||
Assert.Equal(0f, r.AspectAt(0, 1.9)!.Value.Speed1);
|
||||
Assert.Equal(40f, r.AspectAt(0, 2.0)!.Value.Speed1);
|
||||
Assert.Null(r.AspectAt(3, 2.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_still_written_is_read_on_from_where_the_last_read_stopped()
|
||||
{
|
||||
var bytes = Session().Bytes;
|
||||
var whole = BlackBoxReader.Read(new MemoryStream(bytes));
|
||||
|
||||
// Cut at every byte: a record cut short waits for the rest, nothing is read twice.
|
||||
for (int cut = 0; cut <= bytes.Length; cut++)
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(new MemoryStream(bytes, 0, cut));
|
||||
Assert.False(reader.Recording.Complete && cut < bytes.Length);
|
||||
reader.ReadMore(new MemoryStream(bytes));
|
||||
|
||||
Assert.Equal(whole.Tracks.Count, reader.Recording.Tracks.Count);
|
||||
Assert.Equal(whole.Commands.Count, reader.Recording.Commands.Count);
|
||||
Assert.Equal(whole.Cab.Count, reader.Recording.Cab.Count);
|
||||
Assert.Equal(whole.Aspects.Count, reader.Recording.Aspects.Count);
|
||||
Assert.True(reader.Recording.Complete);
|
||||
Assert.Equal(bytes.Length, reader.Offset);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_record_of_a_later_version_is_passed_over_by_its_length()
|
||||
{
|
||||
var file = new BlackBoxFile().Info()
|
||||
.Record(0x7e, w => w.Write(new byte[40]))
|
||||
.Command(1.0, 1, true, "hornhighactivate")
|
||||
.End(2.0);
|
||||
|
||||
var r = BlackBoxReader.Read(file.Stream());
|
||||
|
||||
Assert.Equal(1, r.Unknown);
|
||||
Assert.Equal("hornhighactivate", Assert.Single(r.Commands).Name);
|
||||
Assert.True(r.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_record_shorter_than_its_type_is_passed_over_and_counted()
|
||||
{
|
||||
var file = new BlackBoxFile().Record(0x20, w => w.Write(1.0)).Clock(1.0, 3600).End(2.0);
|
||||
|
||||
var r = BlackBoxReader.Read(file.Stream());
|
||||
|
||||
Assert.Empty(r.Cab);
|
||||
Assert.Equal(1, r.Malformed);
|
||||
Assert.Single(r.Clocks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Texts_longer_than_a_byte_can_count_and_letters_beyond_ascii_read_back()
|
||||
{
|
||||
string name = "żółw_" + new string('x', 300);
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile().Signal(0, name, 0, 0, 0).Signal(1, "", 1, 1, 1).Stream());
|
||||
|
||||
Assert.Equal(name, r.Signal(0)!.Name);
|
||||
Assert.Equal("", r.Signal(1)!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Something_else_than_a_black_box_or_a_version_not_known_is_refused()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new MemoryStream("Time[s] Velocity[m/s]\r\n"u8.ToArray())));
|
||||
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new BlackBoxFile(version: 2).Stream()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_written_anew_under_the_reader_is_told_so()
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(Session().Stream());
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => reader.ReadMore(new BlackBoxFile().Info().Stream()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_listed_only_when_it_moved_stands_until_the_look_before_it_was_seen_moving()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Info()
|
||||
.Vehicle(2, "et22")
|
||||
.Vehicles(0, true, (2, 0, 0, 0))
|
||||
.Vehicles(60, true, (2, 0, 0, 0))
|
||||
.Vehicles(90, false, (2, 0, 10, 36))
|
||||
.Vehicles(91, false, (2, 0, 20, 36))
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(0f, r.VehiclesAt(30).Single().Position.Z);
|
||||
// Not listed from 60 to 90: it stood until 88.5, the look interval and a half before.
|
||||
Assert.Equal(0f, r.VehiclesAt(88).Single().Position.Z);
|
||||
Assert.InRange(r.VehiclesAt(89.25).Single().Position.Z, 4.9f, 5.1f);
|
||||
Assert.InRange(r.VehiclesAt(90.5).Single().Position.Z, 14.9f, 15.1f);
|
||||
Assert.Equal(20f, r.VehiclesAt(200).Single().Position.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_removed_is_on_the_map_no_more()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(2, r.VehiclesAt(4.9).Count);
|
||||
Assert.Equal(1, Assert.Single(r.VehiclesAt(5.0)).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_clock_goes_on_from_the_last_tick_and_over_midnight()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Info(secondOfDay: 86390)
|
||||
.Clock(0, 86390)
|
||||
.Clock(1, 86391)
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(new TimeSpan(23, 59, 51), r.ClockAt(1));
|
||||
Assert.Equal(86395.5, r.ClockAt(5.5).TotalSeconds, 3);
|
||||
Assert.Equal(1.0, r.ClockAt(11).TotalSeconds, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_position_of_the_train_runs_between_its_samples_and_not_across_a_gap()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Cab(0.0, 1, 100, 36)
|
||||
.Cab(0.1, 1, 101, 36)
|
||||
.Cab(5.0, 1, 200, 36)
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(100.5, r.CabPositionAt(0.05)!.Value.Z, 6);
|
||||
Assert.Null(r.CabPositionAt(3.0));
|
||||
Assert.Null(r.CabPositionAt(-1));
|
||||
}
|
||||
}
|
||||
247
StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
Normal file
247
StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Linq;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>The light signals at the head and the tail of the user's train, against those it was to show.</summary>
|
||||
public class HeadCodeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc1)]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper | Lights.HighBeamLeft | Lights.HighBeamRight, HeadCode.Pc1)]
|
||||
[InlineData(Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc2)]
|
||||
[InlineData(Lights.RedMarkerLeft | Lights.RedMarkerRight, HeadCode.Pc5)]
|
||||
[InlineData(Lights.RearEndSignals, HeadCode.Pc5)]
|
||||
[InlineData(Lights.HeadlightRight, HeadCode.Tb1)]
|
||||
[InlineData(Lights.HeadlightLeft, HeadCode.Tb1)]
|
||||
[InlineData(Lights.None, HeadCode.Off)]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight, HeadCode.Other)]
|
||||
public void The_lamps_lit_tell_the_signal(Lights lights, HeadCode code) => Assert.Equal(code, HeadCodes.Of(lights));
|
||||
|
||||
private const ushort Pc1 = 0x15, Pc5 = 0x22, Tb1 = 0x10;
|
||||
|
||||
private static Recording Drive(params (double From, double To, ushort Head, ushort Tail, byte Movement, short HintHead)[] stretches)
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
foreach (var (from, to, head, tail, movement, hint) in stretches)
|
||||
for (double t = from; t < to; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: head, lightsTail: tail, movement: movement, hintHead: hint);
|
||||
return BlackBoxReader.Read(f.End(stretches[^1].To).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_run_is_to_show_Pc1_and_Pc5()
|
||||
{
|
||||
var summary = Drive((0, 60, Pc1, Pc5, 1, -1)).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 55);
|
||||
Assert.Equal(0, summary.WrongLightsSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shunting_lights_on_a_train_run_count_as_wrong_once_for_the_whole_stretch()
|
||||
{
|
||||
var summary = Drive((0, 30, Pc1, Pc5, 1, -1), (30, 60, Tb1, Tb1, 1, -1), (60, 90, Pc1, Pc5, 1, -1)).Summarize();
|
||||
Assert.Equal(30, summary.WrongLightsSeconds, 0);
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Tb1, fault.Head);
|
||||
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
|
||||
Assert.True(fault.HeadWrong && fault.TailWrong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shunting_is_to_show_Tb1()
|
||||
{
|
||||
var summary = Drive((0, 30, Tb1, Tb1, 2, -1), (30, 40, Pc1, Pc5, 2, -1)).Summarize();
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Tb1, fault.HeadRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_pattern_the_timetable_asks_for_is_the_one_to_show()
|
||||
{
|
||||
// Pc 2 asked for at the head: the left red marker, the right headlight and the upper one.
|
||||
const short pc2 = 0x16;
|
||||
var summary = Drive((0, 30, Pc1, Pc5, 1, pc2)).Summarize();
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Pc2, fault.HeadRequired);
|
||||
Assert.False(fault.TailWrong);
|
||||
}
|
||||
|
||||
private static Recording DriveWith(ushort head, ushort tail, ushort equippedHead, ushort equippedTail, byte movement = 1, byte driving = 1)
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, driving: driving, lightsHead: head, lightsTail: tail, movement: movement,
|
||||
equippedHead: equippedHead, equippedTail: equippedTail);
|
||||
return BlackBoxReader.Read(f.End(30).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lamps_the_vehicles_have_are_read()
|
||||
{
|
||||
var sample = DriveWith(Pc1, Pc5, 0x1B5, 0x22).Cab[0];
|
||||
Assert.Equal((Lights)0x1B5, sample.EquippedHead);
|
||||
Assert.Equal((Lights)0x22, sample.EquippedTail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// without the upper headlight Pc 1 is the two others
|
||||
[InlineData(0x11, 0x11)]
|
||||
// with auxiliary lamps, those and the upper headlight
|
||||
[InlineData(0x184, 0x1B5)]
|
||||
// a vehicle with no lamps for it: the rear-end signals
|
||||
[InlineData(0x40, 0x40)]
|
||||
public void Pc1_is_what_the_vehicle_can_show_of_it(ushort lit, ushort equipped)
|
||||
{
|
||||
var summary = DriveWith(lit, Pc5, equipped, Pc5).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 25);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tail_with_the_rear_end_signals_only_shows_Pc5_with_them()
|
||||
{
|
||||
Assert.Empty(DriveWith(Pc1, 0x40, Pc1, 0x40).Summarize().LightsFaults);
|
||||
var fault = Assert.Single(DriveWith(Pc1, 0x00, Pc1, 0x40).Summarize().LightsFaults);
|
||||
Assert.False(fault.HeadWrong);
|
||||
Assert.True(fault.TailWrong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_drives_the_train_and_the_rest_are_read_when_written()
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
f.Cab(0, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22, engine: 4, oilTemperature: 71.5f);
|
||||
f.Cab(0.5, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22);
|
||||
var cab = BlackBoxReader.Read(f.End(1).Stream()).Cab;
|
||||
var extra = Assert.NotNull(cab[0].Extra);
|
||||
Assert.Equal(EngineKind.DieselElectric, extra.Engine);
|
||||
Assert.Equal(3, extra.RadioChannel);
|
||||
Assert.Equal(71.5f, extra.OilTemperature);
|
||||
Assert.Equal(1.0f, extra.Clutch);
|
||||
Assert.Equal(2.5f, extra.ConsistBrakeCylinder);
|
||||
// an older sample has none
|
||||
Assert.Null(cab[1].Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_with_no_lamps_is_not_judged()
|
||||
{
|
||||
var recording = DriveWith(0, 0, 0, 0);
|
||||
Assert.Null(HeadCodes.HeadRight(recording.Cab[0]));
|
||||
Assert.Null(HeadCodes.TailRight(recording.Cab[0]));
|
||||
var summary = recording.Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_with_all_the_lamps_is_to_light_all_three_for_Pc1()
|
||||
{
|
||||
var fault = Assert.Single(DriveWith(0x11, Pc5, 0x35, Pc5).Summarize().LightsFaults);
|
||||
Assert.True(fault.HeadWrong);
|
||||
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lights_the_autopilot_sets_are_not_the_users()
|
||||
{
|
||||
var summary = DriveWith(Tb1, Tb1, Pc1, Pc5, driving: 2).Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_reads_a_pattern_asked_for_as_the_signal_it_shows()
|
||||
{
|
||||
// Pc 5 asked for at the tail, the rear-end signals lit, no lamps written: Pc 5 as before
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Pc1, lightsTail: 0x40, movement: 1, hintTail: 0x22);
|
||||
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 25);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_tells_nothing_of_the_signals()
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Tb1);
|
||||
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The user going over to another vehicle: the timetable of the train is the one of the train the user is in.</summary>
|
||||
public class UserVehicleTests
|
||||
{
|
||||
private const byte Coupled = 0x83;
|
||||
|
||||
[Fact]
|
||||
public void Another_vehicle_of_the_same_train_keeps_its_timetable_another_train_has_none()
|
||||
{
|
||||
// The user in vehicle 1 of the unit 1-2 with ROJ1; at t = 50 over to the other cab, at t = 100 into vehicle 9, on its own.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Coupling(0, 1, 1, 2, 0, Coupled, 0, 0)
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 150; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : t < 100 ? 2u : 9u, 1000, 0);
|
||||
var r = BlackBoxReader.Read(f.End(150).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
|
||||
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
|
||||
Assert.Null(r.TimetableAt(120));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_vehicles_with_no_coupling_written_are_still_told_apart()
|
||||
{
|
||||
// A recording made with the movement written, where no train is longer than one vehicle: no coupling at all.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 100; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : 9u, 1000, 0, movement: 1);
|
||||
var r = BlackBoxReader.Read(f.End(100).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
|
||||
Assert.Null(r.TimetableAt(70));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_with_no_coupling_keeps_the_timetable()
|
||||
{
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 100; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : 2u, 1000, 0);
|
||||
var r = BlackBoxReader.Read(f.End(100).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The departures and the platforms in the summary.</summary>
|
||||
public class StationSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_summary_tells_the_largest_delay_leaving_and_the_stops_off_the_platform()
|
||||
{
|
||||
// Clock 10:34 at t = 0, leaves A at once. Stops at B (10:36 to 10:37) from t = 120, leaves at t = 240 (10:38): a minute late.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "C", ("A", -1, 634), ("B", 636, 637), ("C", 640, -1))
|
||||
.Station(1, 1, 1, 1, "A", 1000)
|
||||
.Station(120, 1, 1, 2, "Stacja_B", 1000, distance: 74.7f, trainLength: 129.8f, platform: 201);
|
||||
for (double t = 0; t < 300; t += 0.5)
|
||||
f.Cab(t, 1, 1000, t is >= 120 and < 240 or < 2 ? 0 : 40);
|
||||
var r = BlackBoxReader.Read(f.End(300).Stream());
|
||||
var summary = r.Summarize();
|
||||
|
||||
Assert.Equal(1, summary.MaxDepartureDelayMinutes!.Value, 1);
|
||||
var off = Assert.Single(summary.OffPlatform);
|
||||
Assert.Equal("B", off.Station);
|
||||
Assert.Equal(3.5, off.Metres, 1);
|
||||
}
|
||||
}
|
||||
98
StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
Normal file
98
StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class DrivingSummaryTests
|
||||
{
|
||||
private static Recording Drive(Func<BlackBoxFile, BlackBoxFile> more)
|
||||
{
|
||||
var file = new BlackBoxFile().Info();
|
||||
return BlackBoxReader.Read(more(file).Stream());
|
||||
}
|
||||
|
||||
/// <summary>Ten samples a second from t, at a speed against an AI holding target.</summary>
|
||||
private static BlackBoxFile Stretch(BlackBoxFile file, double from, double seconds, float speed, float target,
|
||||
byte driving = 1, byte flags = 0x03)
|
||||
{
|
||||
for (int i = 0; i < seconds * 10; i++)
|
||||
file.Cab(from + i * 0.1, 1, 1000 + (from + i * 0.1) * speed / 3.6, speed, driving, flags, target);
|
||||
return file;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40f, 40f, SpeedJudgement.Within)]
|
||||
[InlineData(40.4f, 40f, SpeedJudgement.Within)]
|
||||
[InlineData(44f, 40f, SpeedJudgement.Slightly)]
|
||||
[InlineData(46f, 40f, SpeedJudgement.Over)]
|
||||
[InlineData(-46f, 40f, SpeedJudgement.Over)]
|
||||
public void A_sample_is_judged_by_how_much_faster_than_the_AI_it_went(float speed, float target, SpeedJudgement expected)
|
||||
{
|
||||
var r = Drive(f => f.Cab(0, 1, 0, speed, target: target));
|
||||
|
||||
Assert.Equal(expected, DrivingSummary.Judge(r.Cab[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_AI_worked_out_or_with_the_AI_driving_there_is_nothing_to_judge()
|
||||
{
|
||||
var r = Drive(f => f.Cab(0, 1, 0, 80, flags: 0x01, target: 40).Cab(0.1, 1, 0, 80, driving: 2, target: 40));
|
||||
|
||||
Assert.Equal(SpeedJudgement.NoData, DrivingSummary.Judge(r.Cab[0]));
|
||||
Assert.Equal(SpeedJudgement.Autopilot, DrivingSummary.Judge(r.Cab[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_summary_counts_the_time_over_the_AI_the_autopilot_and_the_signals_passed_at_danger()
|
||||
{
|
||||
var r = Drive(f =>
|
||||
{
|
||||
Stretch(f, 0, 10, 40, 40);
|
||||
Stretch(f, 10, 10, 50, 40);
|
||||
Stretch(f, 20, 10, 43, 40);
|
||||
Stretch(f, 30, 10, 60, 60, driving: 2);
|
||||
f.Passed(15, 1, 0, "s1", 0, 50, 0x02);
|
||||
f.Passed(25, 1, 1, "s2", 40, 43);
|
||||
// Another train, on a track of its own far away.
|
||||
f.Passed(25, 7, 2, "someone else's", 0, 20, 0x02, z: 9000);
|
||||
f.Command(5, 1, true, "mastercontrollerincrease");
|
||||
f.Command(5.1, 1, false, "mastercontrollerincrease");
|
||||
f.Command(6, 0, true, "timejump");
|
||||
return f.Driver(30, 1, 2);
|
||||
});
|
||||
|
||||
var summary = r.Summarize();
|
||||
|
||||
Assert.Equal(20, summary.OverAiSeconds, 0);
|
||||
Assert.Equal(10, summary.WellOverAiSeconds, 0);
|
||||
Assert.Equal(10, summary.MaxOverAiKmh, 3);
|
||||
Assert.InRange(summary.MaxOverAiAt, 10, 20);
|
||||
Assert.Equal(10, summary.AutopilotSeconds, 0);
|
||||
Assert.Equal(30, summary.DrivenSeconds, 0);
|
||||
Assert.Equal(60, summary.MaxSpeedKmh);
|
||||
Assert.Equal(2, summary.SignalsPassed);
|
||||
Assert.Equal("s1", Assert.Single(summary.PassedAtDanger).Event);
|
||||
Assert.Equal(1, summary.Commands);
|
||||
Assert.Equal(1, summary.AutopilotSwitches);
|
||||
// 10 s at 40, 50, 43 and 60 km/h.
|
||||
Assert.InRange(summary.DistanceKm, 0.530, 0.540);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_autopilot_is_told_as_spans_of_time()
|
||||
{
|
||||
var r = Drive(f =>
|
||||
{
|
||||
Stretch(f, 0, 5, 40, 40);
|
||||
Stretch(f, 5, 5, 40, 40, driving: 2);
|
||||
Stretch(f, 10, 5, 40, 40);
|
||||
return Stretch(f, 15, 2, 40, 40, driving: 2);
|
||||
});
|
||||
|
||||
var spans = r.AutopilotSpans();
|
||||
|
||||
Assert.Equal(2, spans.Count);
|
||||
Assert.Equal(5, spans[0].Start, 3);
|
||||
Assert.Equal(10, spans[0].End, 3);
|
||||
Assert.Equal(15, spans[1].Start, 3);
|
||||
}
|
||||
}
|
||||
106
StarterNG.Tests/BlackBox/PlatformTests.cs
Normal file
106
StarterNG.Tests/BlackBox/PlatformTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>The second value of a stop point as the driver reads it: all of it the metres, the last digit also the side.</summary>
|
||||
public class PlatformTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(201f, 201, 1)]
|
||||
[InlineData(242f, 242, 2)]
|
||||
[InlineData(553f, 553, 3)]
|
||||
[InlineData(150f, 150, 0)]
|
||||
[InlineData(-151f, 151, 1)]
|
||||
public void The_whole_value_is_metres_and_the_last_digit_the_side(float value, double length, int side)
|
||||
{
|
||||
Assert.Equal(length, Platforms.Length(value));
|
||||
Assert.Equal(side, Platforms.Side(value));
|
||||
}
|
||||
|
||||
private static StationEvent Stop(float distance, float length, float platform) =>
|
||||
new(0, 1, StationKind.Stopped, 1, "Swoszowice", new Position(0, 0, 0), distance, 63.2f, length, platform, 600, 601);
|
||||
|
||||
[Fact]
|
||||
public void A_train_whose_tail_reaches_past_the_platform_of_201_is_off_it()
|
||||
{
|
||||
// Two EN57 at Swoszowice: the head 74.7 m before the W4, the tail 204.5 m, the platform 201 m.
|
||||
var stop = Stop(74.7f, 129.8f, 201);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(3.5, stop.OffPlatform, 1);
|
||||
Assert.Equal(201, stop.PlatformLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_whose_head_stands_past_the_stop_point_is_off_the_platform_by_that_much()
|
||||
{
|
||||
var stop = Stop(-5f, 129.8f, 201);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(5, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_metre_off_still_counts_as_along_the_platform()
|
||||
{
|
||||
var stop = Stop(-1f, 129.8f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(1, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_end_has_its_own_tolerance()
|
||||
{
|
||||
// the head 1.5 m past the stop point, the tail 1.5 m past the platform's end
|
||||
var stop = Stop(-1.5f, 204f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(3, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_within_the_platform_is_along_it()
|
||||
{
|
||||
var stop = Stop(63.2f, 129.8f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(0, stop.OffPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The way a stop point faces and the W32, as the simulator writes them.</summary>
|
||||
public class StopPointRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_stop_point_tells_the_way_of_the_trains_it_is_for()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, 1.25f).End(10).Bytes));
|
||||
var point = Assert.Single(recording.StopPoints);
|
||||
Assert.True(point.HasHeading);
|
||||
Assert.Equal(1.25f, point.Heading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stop_point_of_an_older_recording_has_no_way()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201).End(10).Bytes));
|
||||
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stop_point_on_tracks_of_both_ways_has_no_way()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, float.NaN).End(10).Bytes));
|
||||
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_W32_is_for_the_trains_shorter_than_its_length_and_that_is_their_room()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Tumlin_po", 100, -1, -121).End(10).Bytes));
|
||||
var point = Assert.Single(recording.StopPoints);
|
||||
Assert.True(point.ForShortTrains);
|
||||
Assert.False(point.HasPlatform);
|
||||
Assert.Equal(121, point.PlatformLength);
|
||||
var stop = new StationEvent(0, 1, StationKind.Stopped, 1, "Tumlin_po", new Position(0, 0, 0), 60, 50, 64.9f, -121, 600, 601);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(3.9, stop.OffPlatform, 1);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,42 +1,93 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.1.11312.151 d18.0
|
||||
VisualStudioVersion = 18.1.11312.151
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox", "Eu07.BlackBox\Eu07.BlackBox.csproj", "{B94F5951-3DC8-4DD8-A694-133B76395A5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Views", "Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj", "{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Viewer", "Eu07.BlackBox.Viewer\Eu07.BlackBox.Viewer.csproj", "{0C131487-66CE-42B9-8BB6-93AAD8C47719}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.Build.0 = Debug|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(RiderSharedRunConfigurations) = postSolution
|
||||
File = linuix.run\StarterNG (linux64).run.xml
|
||||
File = windows.run\StarterNG (win64).run.xml
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -16,15 +16,21 @@ namespace StarterNG.Application;
|
||||
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
|
||||
TapeRole Role = TapeRole.Other);
|
||||
|
||||
/// <summary>One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.</summary>
|
||||
/// <summary>The black box of a run: the whole session in one file, a new one each run.</summary>
|
||||
public sealed record BlackBoxRecording(string Path, DateTime LastWriteUtc, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// One run of the simulator and what is left of its tapes: a later run overwrites those of the
|
||||
/// vehicles it has too. Its black box stays, newest first should there be more.
|
||||
/// </summary>
|
||||
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
|
||||
IReadOnlyList<TapeFile> Tapes);
|
||||
IReadOnlyList<TapeFile> Tapes, IReadOnlyList<BlackBoxRecording> BlackBoxes);
|
||||
|
||||
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
|
||||
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
|
||||
string? Scenery = null, string? Train = null);
|
||||
|
||||
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.</summary>
|
||||
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run - and the black boxes, one a run.</summary>
|
||||
public sealed class HaslerTapes
|
||||
{
|
||||
private const int RememberedLaunches = 30;
|
||||
@@ -251,32 +257,52 @@ public sealed class HaslerTapes
|
||||
var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc)))
|
||||
.Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||
var boxes = _files.DirectoryExists(Folder)
|
||||
? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList()
|
||||
: new List<BlackBoxRecording>();
|
||||
var boxesBehind = boxes.GroupBy(box => launches.LastOrDefault(l => l.Covers(box.LastWriteUtc)))
|
||||
.Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||
|
||||
var sessions = new List<TapeSession>();
|
||||
foreach (var launch in launches)
|
||||
{
|
||||
bool last = ReferenceEquals(launch, launches[^1]);
|
||||
bool running = last && _simulatorRunning();
|
||||
if (!running && !behind.ContainsKey(launch))
|
||||
if (!running && !behind.ContainsKey(launch) && !boxesBehind.ContainsKey(launch))
|
||||
continue;
|
||||
|
||||
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
|
||||
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
|
||||
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
|
||||
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }),
|
||||
boxesBehind.GetValueOrDefault(launch, new List<BlackBoxRecording>())));
|
||||
}
|
||||
|
||||
// What no launch covers goes together by when it was written: a run writes it all at its end.
|
||||
var loose = tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc)))
|
||||
.Select(t => (Written: t.LastWriteUtc, Tape: (TapeFile?)t, Box: (BlackBoxRecording?)null))
|
||||
.Concat(boxes.Where(b => !launches.Any(l => l.Covers(b.LastWriteUtc)))
|
||||
.Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b)))
|
||||
.OrderBy(file => file.Written);
|
||||
var run = new List<TapeFile>();
|
||||
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
|
||||
var runBoxes = new List<BlackBoxRecording>();
|
||||
DateTime? lastWritten = null;
|
||||
foreach (var (written, tape, box) in loose)
|
||||
{
|
||||
if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
|
||||
if (lastWritten is { } before && written - before > QuietBetweenRuns)
|
||||
{
|
||||
sessions.Add(Session(null, running: false, run));
|
||||
sessions.Add(Session(null, running: false, run, runBoxes));
|
||||
run = new List<TapeFile>();
|
||||
runBoxes = new List<BlackBoxRecording>();
|
||||
}
|
||||
run.Add(tape);
|
||||
if (tape is not null)
|
||||
run.Add(tape);
|
||||
if (box is not null)
|
||||
runBoxes.Add(box);
|
||||
lastWritten = written;
|
||||
}
|
||||
if (run.Count > 0)
|
||||
sessions.Add(Session(null, running: false, run));
|
||||
if (lastWritten is not null)
|
||||
sessions.Add(Session(null, running: false, run, runBoxes));
|
||||
|
||||
return sessions.OrderByDescending(session => session.Running)
|
||||
.ThenByDescending(session => session.LastWrittenUtc)
|
||||
@@ -292,13 +318,18 @@ public sealed class HaslerTapes
|
||||
private TapeFile ReadTape(string path) =>
|
||||
new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
|
||||
|
||||
private BlackBoxRecording ReadBlackBox(string path) =>
|
||||
new(path, _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
|
||||
|
||||
/// <summary>The player's own tapes first: every manned vehicle leaves one.</summary>
|
||||
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes)
|
||||
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes,
|
||||
IEnumerable<BlackBoxRecording> boxes)
|
||||
{
|
||||
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
|
||||
var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc);
|
||||
return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started,
|
||||
running, listed);
|
||||
var recorded = boxes.OrderByDescending(box => box.LastWriteUtc).ToList();
|
||||
var written = listed.Select(tape => tape.LastWriteUtc).Concat(recorded.Select(box => box.LastWriteUtc)).ToList();
|
||||
var started = launch?.LaunchedUtc ?? written.Min();
|
||||
return new TapeSession(launch, started, written.Count > 0 ? written.Max() : started, running, listed, recorded);
|
||||
}
|
||||
|
||||
public HaslerTape? Load(TapeFile file)
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The black box of the simulator: its reader and the window it is played back in. -->
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Assets\Logo\" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -6,11 +6,13 @@ using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Eu07.BlackBox.Views;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Domain.Tapes;
|
||||
@@ -57,6 +59,8 @@ public sealed class HaslerTapeWindow : Window
|
||||
private readonly Control _recordingOff;
|
||||
private readonly Control _logOff;
|
||||
private readonly Dictionary<DateTime, bool> _expanded = new();
|
||||
private readonly Dictionary<string, BlackBoxWindow> _blackBoxes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private HashSet<string> _liveBlackBoxes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private TapeFile? _shown;
|
||||
private string? _followed;
|
||||
@@ -102,8 +106,19 @@ public sealed class HaslerTapeWindow : Window
|
||||
ToolTip.SetTip(_startClock, App.Loc["HaslerStartClockTip"]);
|
||||
|
||||
ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
|
||||
_list.KeyDown += async (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter && _list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording box })
|
||||
{
|
||||
e.Handled = true;
|
||||
await OpenBlackBoxAsync(box);
|
||||
}
|
||||
};
|
||||
_list.SelectionChanged += async (_, _) =>
|
||||
{
|
||||
// A black box opens in a window of its own on a click or Enter, not when the arrows go over it; the tape shown stays.
|
||||
if (_list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording })
|
||||
return;
|
||||
// A session stands for its first tape, the player's own if it has one.
|
||||
if (_list.SelectedItem is TreeViewItem { Tag: TapeSession } session)
|
||||
session.IsExpanded = true;
|
||||
@@ -291,7 +306,8 @@ public sealed class HaslerTapeWindow : Window
|
||||
private static string Fingerprint(IEnumerable<TapeSession> sessions) =>
|
||||
string.Join('\n', sessions.Select(session =>
|
||||
$"{session.StartedUtc:O} {session.LastWrittenUtc:yyyyMMddHHmm} {session.Running} " +
|
||||
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}"))));
|
||||
string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}")) + " " +
|
||||
string.Join(' ', session.BlackBoxes.Select(box => box.Path))));
|
||||
|
||||
/// <summary>Sessions newest first, the newest one open until the reader has said otherwise.</summary>
|
||||
private void ShowSessions(IReadOnlyList<TapeSession> sessions, bool toDriven)
|
||||
@@ -304,6 +320,7 @@ public sealed class HaslerTapeWindow : Window
|
||||
if (toDriven)
|
||||
_followed = driven;
|
||||
string? selected = toDriven && driven is not null ? driven : _shown?.Path;
|
||||
FollowBlackBoxes(sessions);
|
||||
|
||||
foreach (var node in _list.Items.OfType<TreeViewItem>())
|
||||
_expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded;
|
||||
@@ -314,7 +331,7 @@ public sealed class HaslerTapeWindow : Window
|
||||
{
|
||||
var node = SessionItem(sessions[i], _expanded.GetValueOrDefault(sessions[i].StartedUtc, i == 0));
|
||||
_list.Items.Add(node);
|
||||
foreach (var tape in node.Items.OfType<TreeViewItem>())
|
||||
foreach (var tape in node.Items.OfType<TreeViewItem>().Where(item => item.Tag is TapeFile))
|
||||
{
|
||||
first ??= (node, tape);
|
||||
if (((TapeFile)tape.Tag!).Path == selected)
|
||||
@@ -337,7 +354,7 @@ public sealed class HaslerTapeWindow : Window
|
||||
_list.SelectedItem = chosen.Tape;
|
||||
}
|
||||
|
||||
private static TreeViewItem SessionItem(TapeSession session, bool open)
|
||||
private TreeViewItem SessionItem(TapeSession session, bool open)
|
||||
{
|
||||
string title = session.Launch is { } launch
|
||||
? string.Join(" · ", new[] { string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName, launch.Train }
|
||||
@@ -372,11 +389,65 @@ public sealed class HaslerTapeWindow : Window
|
||||
Tag = session,
|
||||
IsExpanded = open
|
||||
};
|
||||
foreach (var box in session.BlackBoxes)
|
||||
item.Items.Add(BlackBoxItem(box));
|
||||
foreach (var tape in session.Tapes)
|
||||
item.Items.Add(TapeItem(tape));
|
||||
return item;
|
||||
}
|
||||
|
||||
private TreeViewItem BlackBoxItem(BlackBoxRecording box)
|
||||
{
|
||||
var text = new StackPanel { Spacing = 1 };
|
||||
text.Children.Add(new TextBlock { Text = App.Loc["HaslerBlackBox"], FontWeight = FontWeight.SemiBold });
|
||||
text.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{box.LastWriteUtc.ToLocalTime():HH:mm} · {box.Size / (1024.0 * 1024.0):0.0} MB",
|
||||
FontSize = 11,
|
||||
Opacity = 0.7
|
||||
});
|
||||
ToolTip.SetTip(text, App.Loc["HaslerBlackBoxTip"]);
|
||||
var item = new TreeViewItem { Header = text, Tag = box };
|
||||
// Also when it is the one picked already, and its window was closed since.
|
||||
item.Tapped += async (_, _) => await OpenBlackBoxAsync(box);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>The black box in its window, read on while the session runs; one window a file.</summary>
|
||||
private async Task OpenBlackBoxAsync(BlackBoxRecording box)
|
||||
{
|
||||
if (_blackBoxes.TryGetValue(box.Path, out var open))
|
||||
{
|
||||
open.Activate();
|
||||
return;
|
||||
}
|
||||
var window = new BlackBoxWindow(new StarterBlackBoxStrings())
|
||||
{
|
||||
CanOpenFiles = false,
|
||||
Live = _liveBlackBoxes.Contains(box.Path)
|
||||
};
|
||||
_blackBoxes[box.Path] = window;
|
||||
window.Closed += (_, _) => _blackBoxes.Remove(box.Path);
|
||||
window.Show(this);
|
||||
await window.OpenAsync(box.Path);
|
||||
}
|
||||
|
||||
/// <summary>The newest black box of a running session is still written; once the session ends, its last part is read and that is all.</summary>
|
||||
private void FollowBlackBoxes(IReadOnlyList<TapeSession> sessions)
|
||||
{
|
||||
_liveBlackBoxes = sessions.Where(session => session.Running)
|
||||
.SelectMany(session => session.BlackBoxes.Take(1))
|
||||
.Select(box => box.Path)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (path, window) in _blackBoxes)
|
||||
{
|
||||
if (!window.Live || _liveBlackBoxes.Contains(path))
|
||||
continue;
|
||||
window.ReadMore();
|
||||
window.Live = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TreeViewItem TapeItem(TapeFile file)
|
||||
{
|
||||
var text = new StackPanel { Spacing = 1 };
|
||||
|
||||
21
StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
Normal file
21
StarterNG/Views/Tapes/StarterBlackBoxStrings.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Eu07.BlackBox.Views;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// The texts of the black box window in the starter's language: a translation may give any of
|
||||
/// them as BlackBox + its key; the rest are the window's own, in Polish or else in English.
|
||||
/// </summary>
|
||||
internal sealed class StarterBlackBoxStrings : IBlackBoxStrings
|
||||
{
|
||||
private readonly DefaultBlackBoxStrings _own = new(App.Loc.CurrentLangCode);
|
||||
|
||||
public string this[string key]
|
||||
{
|
||||
get
|
||||
{
|
||||
string text = App.Loc.Opt("BlackBox" + key);
|
||||
return text.Length > 0 ? text : _own[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Relace</String>
|
||||
<String key="HaslerSessionTapes">pásek: {0}</String>
|
||||
<String key="HaslerSessionRunning">běží</String>
|
||||
<String key="HaslerBlackBox">Černá skříňka</String>
|
||||
<String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String>
|
||||
<String key="WarehouseSaved">Ve skladu: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String>
|
||||
<String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String>
|
||||
|
||||
@@ -1079,6 +1079,8 @@
|
||||
<String key="HaslerSession">Session</String>
|
||||
<String key="HaslerSessionTapes">tapes: {0}</String>
|
||||
<String key="HaslerSessionRunning">running</String>
|
||||
<String key="HaslerBlackBox">Black box</String>
|
||||
<String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String>
|
||||
<String key="WarehouseSaved">In the warehouse: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Pick a consist with vehicles</String>
|
||||
<String key="GfxGroupLevelTip">Level for this part of the graphics only</String>
|
||||
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Munkamenet</String>
|
||||
<String key="HaslerSessionTapes">szalag: {0}</String>
|
||||
<String key="HaslerSessionRunning">folyamatban</String>
|
||||
<String key="HaslerBlackBox">Fekete doboz</String>
|
||||
<String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String>
|
||||
<String key="WarehouseSaved">A raktárban: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String>
|
||||
<String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String>
|
||||
|
||||
@@ -901,6 +901,8 @@
|
||||
<String key="HaslerSession">Sesja</String>
|
||||
<String key="HaslerSessionTapes">taśm: {0}</String>
|
||||
<String key="HaslerSessionRunning">trwa</String>
|
||||
<String key="HaslerBlackBox">Czarna skrzynka</String>
|
||||
<String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String>
|
||||
<String key="WarehouseSaved">W magazynie: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String>
|
||||
<String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String>
|
||||
|
||||
@@ -850,6 +850,8 @@
|
||||
<String key="HaslerSession">Сеанс</String>
|
||||
<String key="HaslerSessionTapes">лент: {0}</String>
|
||||
<String key="HaslerSessionRunning">идёт</String>
|
||||
<String key="HaslerBlackBox">Чёрный ящик</String>
|
||||
<String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String>
|
||||
<String key="WarehouseSaved">На складе: {0}</String>
|
||||
<String key="WarehouseNothingToAdd">Выберите состав с транспортом</String>
|
||||
<String key="GfxGroupLevelTip">Уровень только для этой части графики</String>
|
||||
|
||||
Reference in New Issue
Block a user