feat(blackbox): a readable map legend, a key for the timeline's marks, the cab panel in titled parts

- Map legend: a title for each part, the texts in the text colour, the
  speed bands said in full ("up to 5 km/h faster than the AI").
- Timeline: the key names every mark (station, W6, signal passed,
  coupling, autopilot, command), the row of marks has its name, and the
  mark under the pointer is told in a label. The key no longer sits on
  the station plates.
- Cab panel: titled parts (controls, brakes, traction, lights, devices);
  the AI's speed and the next limit no longer run into the controllers;
  the lights of each end with the signal they show, and the one they are
  to show when it differs; the devices in three groups (power, driving,
  safety) instead of one long list.
This commit is contained in:
maj00r
2026-11-11 01:45:40 +01:00
parent 88adf5ee0f
commit 9ee059bc16
4 changed files with 318 additions and 118 deletions

View File

@@ -48,14 +48,36 @@ public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
["AspectStop"] = "stop",
["AspectFree"] = "go",
["AspectSpeed"] = "{0:0}/{1}",
["LegendWithin"] = "within the AI's speed",
["LegendSlightly"] = "up to {0:0} km/h over",
["LegendOver"] = "more over",
["LegendAutopilot"] = "autopilot",
["LegendNoData"] = "no AI data",
["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",
@@ -173,6 +195,12 @@ public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
["CabLightsTail"] = "tail",
["CabSectionBrakes"] = "Brakes",
["CabSectionDrive"] = "Traction",
["CabSectionControls"] = "Controls",
["CabSectionDevices"] = "Devices",
["CabGroupPower"] = "Power",
["CabGroupDriving"] = "Driving",
["CabGroupSafety"] = "Safety",
["CabLightsWant"] = "{0}, should be {1}",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "CA/SHP braking",
@@ -247,14 +275,36 @@ public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
["AspectStop"] = "stój",
["AspectFree"] = "wolna droga",
["AspectSpeed"] = "{0:0}/{1}",
["LegendWithin"] = "w granicach prędkości AI",
["LegendSlightly"] = "do {0:0} km/h ponad",
["LegendOver"] = "więcej ponad",
["LegendAutopilot"] = "autopilot",
["LegendNoData"] = "brak danych AI",
["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",
@@ -372,6 +422,12 @@ public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
["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}",
["DevVigilance"] = "CA",
["DevCabSignal"] = "SHP",
["DevSecurityBraking"] = "Hamowanie CA/SHP",

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
@@ -10,14 +11,14 @@ namespace Eu07.BlackBox.Views;
/// <summary>
/// The cab of the user's train at the moment played: the speed against the AI's, the
/// accelerations, the controllers, the pressures of the brakes as gauges, the traction,
/// the lights at the head and the tail of the train, and a lamp for every device, the
/// alerter (CA) and the cab signalling (SHP) apart.
/// the lights at the head and the tail of the train with what they are to show, and a
/// lamp for every device in three groups (power, driving, safety), the alerter (CA) and
/// the cab signalling (SHP) apart.
/// </summary>
public sealed class CabPanel : Control
{
private const double Pad = 14;
private const double LampRow = 16;
private const int LampColumns = 3;
private const double LampRow = 15;
private Recording? _recording;
private double _time;
@@ -27,7 +28,7 @@ public sealed class CabPanel : Control
public CabPanel()
{
ClipToBounds = true;
Height = 428;
Height = 506;
}
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
@@ -56,7 +57,7 @@ public sealed class CabPanel : Control
public override void Render(DrawingContext context)
{
var size = Bounds.Size;
double width = size.Width - 2 * Pad;
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)
@@ -67,44 +68,40 @@ public sealed class CabPanel : Control
Measure(recording);
var culture = CultureInfo.CurrentCulture;
// Who drives, from which cab of which vehicle.
double chipX = size.Width - Pad;
string name = recording.Vehicle(s.Vehicle)?.Name ?? "";
// 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, 10) + 5;
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, 10) + 8;
var vehicle = MapView.Text(name, Palette.FaintText, 11);
if (chipX - vehicle.Width > Pad + 70)
context.DrawText(vehicle, new Point(chipX - vehicle.Width, 13));
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, and what the AI would do.
double top = 38;
// 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, 38, FontWeight.SemiBold);
context.DrawText(speed, new Point(Pad, top - 4));
context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, top + speed.Height - 24));
: 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)
{
context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12),
new Point(Pad, top + speed.Height - 4));
if (s.Ai.HasNextLimit)
context.DrawText(MapView.Text(string.Format(culture, Strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance), Palette.Limit, 11),
new Point(Pad, top + speed.Height + 12));
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));
}
}
// The accelerations, as needles from the middle: along the track and across it.
double gaugeX = Pad + Math.Max(150, width * 0.42);
double gaugeWidth = size.Width - Pad - gaugeX;
Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, top + 2, gaugeWidth);
Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, top + 34, gaugeWidth);
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.
top = 112;
Divider(context, top - 8, size.Width);
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);
@@ -113,54 +110,83 @@ public sealed class CabPanel : Control
Value(context, Strings["CabTrainBrake"], s.TrainBrake.ToString("0.0", culture), Pad + 3 * column, top);
// The brakes, as gauges.
top = 160;
Divider(context, top - 6, size.Width);
top = Section(context, Strings["CabSectionBrakes"], Pad, 180, right);
Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, top + 18, width);
Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, top + 36, width);
Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, top + 54, width);
// The traction and the lights at both ends of the train.
top = 242;
Divider(context, top - 6, size.Width);
double third = width * 0.62 / 3;
// The traction at the left, the lights of both ends of the train at the right, one under the other.
double half = Pad + width / 2;
top = Section(context, Strings["CabSectionDrive"], Pad, 272, half - 10);
Section(context, Strings["CabLights"], half + 4, 272, right);
double third = (half - 10 - Pad) / 3;
Value(context, Strings["CabVoltage"], $"{s.Voltage:0} V", Pad, top);
Value(context, Strings["CabCurrent"], $"{s.MotorCurrent:0} A", Pad + third, top);
Value(context, Strings["CabRpm"], $"{s.EngineRpm:0}", Pad + 2 * third, top);
double lightsX = Pad + 3 * third + 4;
context.DrawText(MapView.Text(Strings["CabLights"], Palette.DimText, 10), new Point(lightsX, top));
double faceX = lightsX;
foreach (var (key, lights, required, right) in new[]
double row = top;
foreach (var (key, lights, required, isRight) in new[]
{
("CabLightsHead", s.LightsHead, HeadCodes.RequiredHead(s), HeadCodes.HeadRight(s)),
("CabLightsTail", s.LightsTail, HeadCodes.RequiredTail(s), HeadCodes.TailRight(s))
})
{
DrawFace(context, lights, new Point(faceX + 18, top + 24));
// the end, and 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
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 = right == false && required is { } want
? $"{HeadCodes.Name(shown)} ≠ {HeadCodes.Name(want)}"
: right == true && required is { } met ? HeadCodes.Name(met) : HeadCodes.Name(shown);
var brush = right is null ? Palette.FaintText : right.Value ? Palette.Good : Palette.Bad;
var label = MapView.Text($"{Strings[key]} {text}", brush, 9);
context.DrawText(label, new Point(faceX + 18 - label.Width / 2, top + 34));
faceX += 62;
var text = 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 lamps.
top = 292;
Divider(context, top - 6, size.Width);
int i = 0;
double lampColumn = width / LampColumns;
foreach (var (key, brush) in Lamps(s))
// 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, 350, right);
var groups = Devices(s);
var widths = new double[groups.Length];
for (int g = 0; g < groups.Length; g++)
{
var at = new Point(Pad + i % LampColumns * lampColumn, top + i / LampColumns * LampRow);
Led(context, at + new Vector(5, 7), brush);
context.DrawText(MapView.Text(Strings[key], brush is null ? Palette.FaintText : Palette.Text, 10.5), at + new Vector(14, 0));
i++;
widths[g] = MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold).Width;
foreach (var (key, _) in groups[g].Lamps)
widths[g] = Math.Max(widths[g], 14 + MapView.Text(Strings[key], 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;
for (int g = 0; g < groups.Length; g++)
{
double groupWidth = Math.Max(20, widths[g] + spare);
using (context.PushClip(new Rect(x, top, groupWidth - 4, size.Height - top)))
{
context.DrawText(MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold), new Point(x, top));
double y = top + 16;
foreach (var (key, brush) in groups[g].Lamps)
{
Led(context, new Point(x + 4, y + 7), brush);
context.DrawText(MapView.Text(Strings[key], brush is null ? Palette.FaintText : Palette.Text, 10.5), new Point(x + 14, y));
y += LampRow;
}
}
x += groupWidth;
}
}
/// <summary>The title of a part of the panel, with a line from it to the right; the top of the part returned.</summary>
private static double Section(DrawingContext context, string title, double x, double y, double right)
{
var text = MapView.Text(title.ToUpper(CultureInfo.CurrentCulture), Palette.DimText, 9, FontWeight.SemiBold);
context.DrawText(text, new Point(x, y));
double line = Math.Round(y + text.Height / 2) + 0.5;
if (x + text.Width + 8 < right)
context.DrawLine(new Pen(Palette.Grid, 1), new Point(x + text.Width + 8, line), new Point(right, line));
return y + 17;
}
/// <summary>The highest step of the controller used in the recording, for its bar.</summary>
@@ -175,9 +201,6 @@ public sealed class CabPanel : Control
_measured = recording.Revision;
}
private static void Divider(DrawingContext context, double y, double width) =>
context.DrawLine(new Pen(Palette.Grid, 1), new Point(Pad, y + 0.5), new Point(width - Pad, y + 0.5));
private static void Value(DrawingContext context, string label, string value, double x, double y)
{
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
@@ -269,40 +292,48 @@ public sealed class CabPanel : Control
context.DrawEllipse(lit ?? Palette.LampOff, null, center, 3.5, 3.5);
}
/// <summary>Every device, with the colour of its lamp if it was on.</summary>
private static IEnumerable<(string Key, IBrush? Lit)> Lamps(CabSample s)
/// <summary>Every device, with the colour of its lamp if it was on, in the groups the panel shows.</summary>
private static (string Title, (string Key, IBrush? Lit)[] Lamps)[] Devices(CabSample s)
{
IBrush? On(CabDevices device, IBrush colour) => s.Has(device) ? colour : null;
IBrush? Flag(CabFlags flag, IBrush colour) => (s.Flags & flag) != 0 ? colour : null;
IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
yield return ("DevBattery", On(CabDevices.Battery, Palette.LampOn));
yield return ("DevMainBreaker", On(CabDevices.MainBreaker, Palette.LampOn));
yield return ("DevConverter", On(CabDevices.Converter, Palette.LampOn));
yield return ("DevPantographFront", On(CabDevices.PantographFront, Palette.LampOn));
yield return ("DevPantographRear", On(CabDevices.PantographRear, Palette.LampOn));
yield return ("DevCompressor", On(CabDevices.Compressor, Palette.LampOn));
yield return ("DevLineContactors", On(CabDevices.LineContactors, Palette.LampOn));
yield return ("DevHeating", On(CabDevices.Heating, Palette.LampOn));
yield return ("DevRadio", On(CabDevices.Radio, Palette.LampOn));
yield return ("DevShuntMode", On(CabDevices.ShuntMode, Palette.LampOn));
yield return ("DevDoorsLeft", On(CabDevices.DoorsLeft, Palette.LampWarning));
yield return ("DevDoorsRight", On(CabDevices.DoorsRight, Palette.LampWarning));
yield return ("DevSanding", On(CabDevices.Sanding, Palette.LampWarning));
yield return ("DevHorn", On(CabDevices.Horn, Palette.LampWarning));
yield return ("DevSpringBrake", On(CabDevices.SpringBrake, Palette.LampWarning));
var power = new[]
{
("DevBattery", On(CabDevices.Battery, Palette.LampOn)),
("DevMainBreaker", On(CabDevices.MainBreaker, Palette.LampOn)),
("DevPantographFront", On(CabDevices.PantographFront, Palette.LampOn)),
("DevPantographRear", On(CabDevices.PantographRear, Palette.LampOn)),
("DevConverter", On(CabDevices.Converter, Palette.LampOn)),
("DevCompressor", On(CabDevices.Compressor, Palette.LampOn)),
("DevLineContactors", On(CabDevices.LineContactors, Palette.LampOn)),
("DevHeating", On(CabDevices.Heating, Palette.LampOn))
};
var driving = new[]
{
("DevShuntMode", On(CabDevices.ShuntMode, Palette.LampOn)),
("DevRadio", On(CabDevices.Radio, Palette.LampOn)),
("DevSanding", On(CabDevices.Sanding, Palette.LampWarning)),
("DevHorn", On(CabDevices.Horn, Palette.LampWarning)),
("DevDoorsLeft", On(CabDevices.DoorsLeft, Palette.LampWarning)),
("DevDoorsRight", On(CabDevices.DoorsRight, Palette.LampWarning)),
("DevSpringBrake", On(CabDevices.SpringBrake, Palette.LampWarning))
};
var safety = new List<(string, IBrush?)>();
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
{
// A recording which tells them apart.
yield return ("DevVigilance", Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance));
yield return ("DevCabSignal", Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal));
yield return ("DevSecurityBraking", Security(SecurityFlags.Braking, Palette.LampAlarm));
safety.Add(("DevVigilance", Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance)));
safety.Add(("DevCabSignal", Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal)));
safety.Add(("DevSecurityBraking", Security(SecurityFlags.Braking, Palette.LampAlarm)));
}
else
yield return ("DevAlerter", Flag(CabFlags.AlerterBeeping, Palette.LampAlarm) ?? Flag(CabFlags.AlerterBlinking, Palette.LampWarning));
yield return ("DevWheelSlip", Flag(CabFlags.WheelSlip, Palette.LampAlarm));
yield return ("DevOverloadRelay", On(CabDevices.OverloadRelay, Palette.LampAlarm));
yield return ("DevRadioStop", Flag(CabFlags.RadioStop, Palette.LampAlarm));
yield return ("DevAlarmChain", Flag(CabFlags.AlarmChain, Palette.LampAlarm));
safety.Add(("DevAlerter", Flag(CabFlags.AlerterBeeping, Palette.LampAlarm) ?? Flag(CabFlags.AlerterBlinking, Palette.LampWarning)));
safety.Add(("DevWheelSlip", Flag(CabFlags.WheelSlip, Palette.LampAlarm)));
safety.Add(("DevOverloadRelay", On(CabDevices.OverloadRelay, Palette.LampAlarm)));
safety.Add(("DevRadioStop", Flag(CabFlags.RadioStop, Palette.LampAlarm)));
safety.Add(("DevAlarmChain", Flag(CabFlags.AlarmChain, Palette.LampAlarm)));
return [("CabGroupPower", power), ("CabGroupDriving", driving), ("CabGroupSafety", safety.ToArray())];
}
}

View File

@@ -412,7 +412,7 @@ public sealed class MapView : Control
}
}
private static StreamGeometry Diamond(Point p, double size)
internal static StreamGeometry Diamond(Point p, double size)
{
var geometry = new StreamGeometry();
using (var g = geometry.Open())
@@ -546,7 +546,7 @@ public sealed class MapView : Control
{
(Palette.Within, Strings["LegendWithin"]),
(Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)),
(Palette.Over, Strings["LegendOver"]),
(Palette.Over, string.Format(CultureInfo.CurrentCulture, Strings["LegendOver"], DrivingSummary.DefaultTolerance)),
(Palette.Autopilot, Strings["LegendAutopilot"]),
(Palette.NoData, Strings["LegendNoData"])
};
@@ -562,25 +562,33 @@ public sealed class MapView : Control
if (r.Cameras.Count > 0)
marks.Add((Palette.HornPoint, Strings["LegendCamera"], 2));
}
var texts = rows.Select(row => Text(row.Text, Palette.DimText, 11)).Concat(marks.Select(m => Text(m.Text, Palette.DimText, 11))).ToList();
const double line = 17;
double width = texts.Max(t => t.Width) + 44, height = texts.Count * line + 14 + (marks.Count > 0 ? 6 : 0);
// 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.9), new Pen(Palette.CardBorder, 1), box);
double y = box.Top + 7;
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, 4, lineCap: PenLineCap.Round), new Point(box.Left + 12, y + 8), new Point(box.Left + 26, y + 8));
context.DrawText(texts[i], new Point(box.Left + 34, y));
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)
{
context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y + 3), new Point(box.Right - 10, y + 3));
y += 6;
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 + 19, y + 8);
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)
@@ -589,7 +597,7 @@ public sealed class MapView : Control
context.DrawGeometry(marks[i].Brush, null, Diamond(c, 4.5));
else
context.DrawEllipse(marks[i].Brush, null, c, 4, 4);
context.DrawText(texts[rows.Length + i], new Point(box.Left + 34, y));
context.DrawText(texts[rows.Length + i], new Point(box.Left + 36, y));
}
}

View File

@@ -20,7 +20,7 @@ public sealed class TimelineView : Control
{
private const double Left = 56;
private const double Right = 12;
private const double Top = 22;
private const double Top = 36;
private const double BandHeight = 8;
private const double BandGap = 3;
private const double MarksHeight = 16;
@@ -35,6 +35,7 @@ public sealed class TimelineView : Control
private double _pressedStart;
private bool _dragged;
private double? _hover;
private Point? _pointer;
private int _measured = -1;
private Recording? _measuredOf;
private double _speedTop = 40;
@@ -134,6 +135,8 @@ public sealed class TimelineView : Control
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>
@@ -330,6 +333,9 @@ public sealed class TimelineView : Control
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>
@@ -427,14 +433,111 @@ public sealed class TimelineView : Control
private void DrawKey(DrawingContext context)
{
double x = Left + 4;
foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") })
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)
{
context.DrawLine(new Pen(brush, 2), new Point(x, 10), new Point(x + 14, 10));
var text = MapView.Text(Strings[key], Palette.DimText, 10);
context.DrawText(text, new Point(x + 18, 10 - text.Height / 2));
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)
return null;
var culture = CultureInfo.CurrentCulture;
string? found = null;
double best = 5;
void Consider(double time, Func<string> text)
{
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.Clamp(pointer.X + 10, Left, 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)
@@ -468,6 +571,7 @@ public sealed class TimelineView : Control
_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();
}
@@ -485,6 +589,7 @@ public sealed class TimelineView : Control
{
base.OnPointerExited(e);
_hover = null;
_pointer = null;
Hovered?.Invoke(null);
InvalidateVisual();
}