mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Add Hasler tape viewer for the simulator's physics logs
Reads physicslog/*.dat and draws each trip as a Hasler tape or, for vehicles with an electronic recorder, as an evaluation screen. Tapes are grouped by launch, and a launch still running can be followed live. Includes whole-tape PNG export and a summary of times and averages. With the log off, the page offers to turn it on.
This commit is contained in:
@@ -37,6 +37,7 @@ public sealed class TestInstallation
|
||||
|
||||
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
||||
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
||||
Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
|
||||
}
|
||||
|
||||
public InMemoryFileSystem Files { get; }
|
||||
@@ -57,6 +58,8 @@ public sealed class TestInstallation
|
||||
public SettingsPaths SettingsPaths { get; }
|
||||
public ExecutableLocator Executables { get; }
|
||||
public SettingsStore SettingsStore { get; }
|
||||
public HaslerTapes Tapes { get; }
|
||||
public bool SimulatorRunning { get; set; }
|
||||
|
||||
/// <summary>Path of a file inside the fake installation.</summary>
|
||||
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
|
||||
|
||||
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Globalization;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class HaslerTapeTests
|
||||
{
|
||||
private const string Header =
|
||||
"Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " +
|
||||
"BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " +
|
||||
"Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n";
|
||||
|
||||
private static string Line(double t, double v, double brake, double current, string command = "none",
|
||||
int security = 0) =>
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") +
|
||||
$"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n";
|
||||
|
||||
[Fact]
|
||||
public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header
|
||||
+ Line(10, 0, 0.45, 0)
|
||||
+ Line(20, 36, 0, 400)
|
||||
+ Line(30, 72, 0, 0, security: 1));
|
||||
|
||||
Assert.Equal(3, tape.Samples.Count);
|
||||
Assert.Equal(72, tape.MaxSpeedKmh, 6);
|
||||
Assert.Equal(20, tape.Duration, 6);
|
||||
Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each
|
||||
Assert.True(tape.Samples[0].Braking);
|
||||
Assert.True(tape.Samples[1].Current);
|
||||
Assert.True(tape.Samples[2].SecurityAlarm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standing_and_running_time_split_the_recording_like_the_old_analyser()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400)
|
||||
+ Line(180, 36, 0, 0));
|
||||
|
||||
Assert.Equal(60, tape.StandingTime, 6);
|
||||
Assert.Equal(120, tape.RunningTime, 6);
|
||||
Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it
|
||||
Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header +
|
||||
"107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " +
|
||||
"0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n");
|
||||
|
||||
var s = Assert.Single(tape.Samples);
|
||||
Assert.Equal(38.6099, s.SpeedKmh, 6);
|
||||
Assert.Equal(4.9981, s.PipePressBar, 6);
|
||||
Assert.False(s.Braking);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0");
|
||||
|
||||
Assert.Equal(2, tape.Samples.Count);
|
||||
Assert.Equal(5, tape.DistanceM, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0));
|
||||
|
||||
Assert.Equal(1, tape.IndexAt(1.5, byDistance: false));
|
||||
Assert.Equal(2, tape.IndexAt(25, byDistance: true));
|
||||
Assert.Equal(0, tape.IndexAt(-3, byDistance: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 90)]
|
||||
[InlineData(90, 90)]
|
||||
[InlineData(90.5, 100)]
|
||||
[InlineData(120, 120)]
|
||||
[InlineData(151, 180)]
|
||||
[InlineData(180, 180)]
|
||||
[InlineData(181, 200)]
|
||||
[InlineData(205, 220)]
|
||||
public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) =>
|
||||
Assert.Equal(top, TapeFigures.TopSpeed(max));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 10)]
|
||||
[InlineData(0.8, 10)]
|
||||
[InlineData(0.79, 20)]
|
||||
[InlineData(0.4, 20)]
|
||||
[InlineData(0.16, 50)]
|
||||
[InlineData(0.08, 100)]
|
||||
[InlineData(0.04, 200)]
|
||||
[InlineData(0.039, 500)]
|
||||
public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) =>
|
||||
Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh));
|
||||
|
||||
[Theory]
|
||||
[InlineData(60, 1)]
|
||||
[InlineData(59, 2)]
|
||||
[InlineData(30, 2)]
|
||||
[InlineData(12, 5)]
|
||||
[InlineData(6, 10)]
|
||||
[InlineData(4, 15)]
|
||||
[InlineData(2, 30)]
|
||||
[InlineData(1, 60)]
|
||||
[InlineData(0.99, 120)]
|
||||
public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) =>
|
||||
Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute));
|
||||
|
||||
[Theory]
|
||||
[InlineData(600, 0.1)]
|
||||
[InlineData(500, 0.1)]
|
||||
[InlineData(499, 0.2)]
|
||||
[InlineData(100, 0.5)]
|
||||
[InlineData(50, 1)]
|
||||
[InlineData(25, 2)]
|
||||
[InlineData(10, 5)]
|
||||
[InlineData(5, 10)]
|
||||
[InlineData(2.5, 20)]
|
||||
[InlineData(2, 50)]
|
||||
public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) =>
|
||||
Assert.Equal(step, TapeFigures.KmStep(pxPerKm));
|
||||
|
||||
[Theory]
|
||||
[InlineData(-5, "0:00")]
|
||||
[InlineData(0, "0:00")]
|
||||
[InlineData(65, "1:05")]
|
||||
[InlineData(3599, "59:59")]
|
||||
[InlineData(3600, "1:00:00")]
|
||||
[InlineData(3723, "1:02:03")]
|
||||
[InlineData(90000, "25:00:00")]
|
||||
public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) =>
|
||||
Assert.Equal(text, TapeFigures.Clock(seconds));
|
||||
|
||||
[Theory]
|
||||
[InlineData("7:52", 7, 52, 0)]
|
||||
[InlineData("07:52", 7, 52, 0)]
|
||||
[InlineData(" 23:05 ", 23, 5, 0)]
|
||||
[InlineData("7:52:30", 7, 52, 30)]
|
||||
[InlineData("24:00", 0, 0, 0)]
|
||||
[InlineData("noon", 0, 0, 0)]
|
||||
[InlineData("", 0, 0, 0)]
|
||||
[InlineData(null, 0, 0, 0)]
|
||||
public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) =>
|
||||
Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text));
|
||||
|
||||
[Fact]
|
||||
public void Tapes_are_listed_from_physicslog_newest_first()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0)));
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
var list = tapes.List();
|
||||
|
||||
Assert.Single(list);
|
||||
Assert.Equal("EU07-424", list[0].Vehicle);
|
||||
Assert.Single(tapes.Load(list[0])!.Samples);
|
||||
}
|
||||
}
|
||||
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
@@ -0,0 +1,485 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TapeLaunchTests
|
||||
{
|
||||
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void A_launch_is_written_and_read_back_with_names_that_hold_spaces()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[]
|
||||
{
|
||||
new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"),
|
||||
new TapeVehicle("wagon 1", "pkp/111a_v2", "111a")
|
||||
});
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal(Launched, back.LaunchedUtc);
|
||||
Assert.Equal("td.scn", back.Scenery);
|
||||
Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock);
|
||||
Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder);
|
||||
Assert.Equal("111a", back.Find("wagon 1")!.TypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_driven_vehicle_and_the_consist_are_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
"EP07 424", new[] { "EP07 424", "wagon 1" });
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424"));
|
||||
Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1"));
|
||||
Assert.Equal(TapeRole.Other, back.RoleOf("et22-100"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_note_without_the_driven_vehicle_tells_nothing_of_it()
|
||||
{
|
||||
var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!;
|
||||
|
||||
Assert.Null(back.Driven);
|
||||
Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424"));
|
||||
Assert.Null(back.SceneryName);
|
||||
Assert.Null(back.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scenario_and_the_train_are_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
SceneryName: "Tor doświadczalny", Train: "EIE 1531");
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal("Tor doświadczalny", back.SceneryName);
|
||||
Assert.Equal("EIE 1531", back.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_the_last_launch_gets_its_scenario_and_train()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||
"Tor doświadczalny", "EIE1531");
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||
Assert.Equal("EIE1531", context.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||
"Tor doświadczalny", "EIE1531");
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||
Assert.Null(context.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenario_without_a_name_goes_by_its_file()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")]
|
||||
[InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")]
|
||||
[InlineData("[ Kategoria | IC ]\r\n", null)]
|
||||
[InlineData("", null)]
|
||||
public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number)
|
||||
{
|
||||
var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable);
|
||||
|
||||
Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||
"baltyk/eie1531"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("NONE")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void A_trainset_without_a_timetable_has_no_train_number(string? name) =>
|
||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
|
||||
|
||||
[Fact]
|
||||
public void A_timetable_that_is_not_there_gives_no_train_number() =>
|
||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||
@"baltyk\mod\eie1531"));
|
||||
|
||||
[Fact]
|
||||
public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok", new[] { "c-lok", "b-wagon" });
|
||||
|
||||
Assert.Equal("c-lok", tapes.DrivenVehicle);
|
||||
Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle));
|
||||
Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Where_the_player_has_been_is_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n"));
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal("b", back.Player!.Current);
|
||||
Assert.True(back.Player.Used("a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_note_of_a_single_launch_still_reads()
|
||||
{
|
||||
var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n");
|
||||
|
||||
Assert.Equal("td.scn", all.Single().Scenery);
|
||||
Assert.Null(all.Single().Player);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tapes_part_between_the_launches_they_were_written_after()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t");
|
||||
var installation = new TestInstallation(files);
|
||||
var tapes = installation.Tapes;
|
||||
installation.Clock.Now = Launched;
|
||||
tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A", "T1");
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty<TapeVehicle>(), "rb", new[] { "rb" }, "B", "T2");
|
||||
files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30))
|
||||
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90));
|
||||
|
||||
var sessions = tapes.Sessions();
|
||||
|
||||
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
|
||||
Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle);
|
||||
Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle);
|
||||
Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock);
|
||||
Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written()
|
||||
{
|
||||
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "a.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "b.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "c.dat"), "t")
|
||||
.WrittenAt(TestInstallation.At("physicslog", "a.dat"), early)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30))
|
||||
.WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1)));
|
||||
|
||||
var sessions = installation.Tapes.Sessions();
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count));
|
||||
Assert.All(sessions, session => Assert.Null(session.Launch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
|
||||
|
||||
installation.SimulatorRunning = true;
|
||||
var running = tapes.Sessions().Single();
|
||||
Assert.True(running.Running);
|
||||
Assert.Empty(running.Tapes);
|
||||
|
||||
installation.SimulatorRunning = false;
|
||||
Assert.Empty(tapes.Sessions());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale()
|
||||
{
|
||||
string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt");
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n")
|
||||
.WithFile(kept, "rb received command: [x]\n")
|
||||
.WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2))
|
||||
.WrittenAt(kept, Launched.AddMinutes(10)));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
var written = Launched.AddMinutes(30);
|
||||
installation.Files.WrittenAt(TestInstallation.At("log.txt"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written);
|
||||
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "inny", new[] { "inny" });
|
||||
|
||||
var run = tapes.Sessions().Single();
|
||||
Assert.Equal("Test", run.Launch!.SceneryName);
|
||||
Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role);
|
||||
Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_last_launches_are_remembered()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
var tapes = installation.Tapes;
|
||||
for (int i = 0; i < 35; i++)
|
||||
{
|
||||
installation.Clock.Now = Launched.AddMinutes(i);
|
||||
tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
}
|
||||
|
||||
var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText(
|
||||
TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8));
|
||||
|
||||
Assert.Equal(30, noted.Count);
|
||||
Assert.Equal("s5.scn", noted[0].Scenery);
|
||||
Assert.Equal("s34.scn", noted[^1].Scenery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in()
|
||||
{
|
||||
var player = PlayerVehicles.None.After(
|
||||
"[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" +
|
||||
"[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" +
|
||||
"[ 255.539 ]\t\tKey pressed: [F5]\r\n" +
|
||||
"[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" +
|
||||
"lok-c received command: [cabchangebackward]\n");
|
||||
|
||||
Assert.Equal("lok-c", player.Current);
|
||||
Assert.True(player.Used("LOK-A"));
|
||||
Assert.True(player.Used("lok b"));
|
||||
Assert.False(player.Used("lok-d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Going_back_to_a_vehicle_makes_it_the_current_one_again()
|
||||
{
|
||||
var player = PlayerVehicles.None
|
||||
.After("a received command: [x]\nb received command: [x]\n")
|
||||
.After("A received command: [x]\n");
|
||||
|
||||
Assert.Equal("A", player.Current);
|
||||
Assert.True(player.Used("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
"ra", new[] { "ra", "s", "rb" });
|
||||
var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n");
|
||||
|
||||
Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player));
|
||||
Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player));
|
||||
Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_first_command_the_vehicle_of_the_launch_is_driven()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None));
|
||||
}
|
||||
|
||||
private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log)
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t")
|
||||
.WithFile(TestInstallation.At("log.txt"), log));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1");
|
||||
return (tapes, installation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train()
|
||||
{
|
||||
var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
|
||||
Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle));
|
||||
Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first()
|
||||
{
|
||||
var (tapes, _) = TapesOfTrain("obcy received command: [x]\n");
|
||||
|
||||
var driven = tapes.List().First();
|
||||
|
||||
Assert.Equal("obcy", driven.Vehicle);
|
||||
Assert.Null(tapes.ContextOf(driven).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look()
|
||||
{
|
||||
const string start = "ra received command: [x]\n";
|
||||
var (tapes, installation) = TapesOfTrain(start);
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com");
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n");
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n");
|
||||
|
||||
Assert.Equal("obcy", tapes.DrivenVehicle);
|
||||
Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_older_than_the_launch_says_nothing_of_it()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("rb received command: [x]\n");
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_older_than_the_last_launch_has_no_part_in_it()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok");
|
||||
|
||||
Assert.Equal(TapeRole.Other, tapes.List().Single().Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_written_before_the_launch_is_not_taken_for_one_of_it()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.True(launch.Covers(Launched.AddMinutes(3)));
|
||||
Assert.False(launch.Covers(Launched.AddHours(-1)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)]
|
||||
[InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)]
|
||||
[InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)]
|
||||
[InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)]
|
||||
public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) =>
|
||||
Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok"));
|
||||
|
||||
[Fact]
|
||||
public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by()
|
||||
{
|
||||
Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001"));
|
||||
Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_the_last_launch_gets_its_clock_and_recorder()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0),
|
||||
new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") });
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock);
|
||||
Assert.Equal(RecorderKind.Electronic, context.Recorder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t"));
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock);
|
||||
}
|
||||
}
|
||||
308
StarterNG/Application/HaslerTapes.cs
Normal file
308
StarterNG/Application/HaslerTapes.cs
Normal file
@@ -0,0 +1,308 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
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>
|
||||
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
|
||||
IReadOnlyList<TapeFile> Tapes);
|
||||
|
||||
/// <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>
|
||||
public sealed class HaslerTapes
|
||||
{
|
||||
private const int RememberedLaunches = 30;
|
||||
private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly IClock _clock;
|
||||
private readonly TimetableLocator _timetables;
|
||||
private readonly Func<bool> _simulatorRunning;
|
||||
private readonly object _playerLock = new();
|
||||
private IReadOnlyList<TapeLaunch>? _launches;
|
||||
private (DateTime Written, long Size) _launchesAt;
|
||||
private PlayerVehicles _player = PlayerVehicles.None;
|
||||
private DateTime _playerLaunch;
|
||||
private string? _playerLog;
|
||||
private long _playerLogRead;
|
||||
|
||||
public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock,
|
||||
TimetableLocator timetables, Func<bool> simulatorRunning)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_clock = clock;
|
||||
_timetables = timetables;
|
||||
_simulatorRunning = simulatorRunning;
|
||||
}
|
||||
|
||||
public string Folder => _paths.FromRoot("physicslog");
|
||||
|
||||
/// <summary>Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one.</summary>
|
||||
private string LaunchFile => Path.Combine(Folder, "starter-launch.txt");
|
||||
|
||||
/// <summary>
|
||||
/// The simulator logs every driven vehicle under its scenery name, with the
|
||||
/// time counted from the start; the clock it starts at and what each name is
|
||||
/// are only known here. So is whose tape is the player's: a scenery with no
|
||||
/// consist picked starts with the free camera, in no vehicle at all.
|
||||
/// </summary>
|
||||
public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven)
|
||||
{
|
||||
var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm",
|
||||
CultureInfo.InvariantCulture, out var over)
|
||||
? over
|
||||
: TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time)
|
||||
? time
|
||||
: TimeSpan.Zero;
|
||||
|
||||
var own = trainset?.Vehicles ?? new List<Dynamic>();
|
||||
var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own)
|
||||
.Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? ""));
|
||||
RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven,
|
||||
driven is null ? null : own.Select(v => v.Name), scenery.Name,
|
||||
driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name));
|
||||
}
|
||||
|
||||
/// <summary>Notes down, next to the logs the simulator is about to write, what only the starter knows.</summary>
|
||||
public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable<TapeVehicle> vehicles,
|
||||
string? driven = null, IEnumerable<string>? consist = null,
|
||||
string? sceneryName = null, string? train = null)
|
||||
{
|
||||
var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock,
|
||||
vehicles.Where(v => !string.IsNullOrEmpty(v.Name))
|
||||
.DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
driven, consist?.ToList(), sceneryName, train);
|
||||
try
|
||||
{
|
||||
var noted = Launches().ToList();
|
||||
// The log is about to begin anew: what it says of the run before stays with that run.
|
||||
if (noted.Count > 0)
|
||||
noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) };
|
||||
noted.Add(launch);
|
||||
|
||||
_files.CreateDirectory(Folder);
|
||||
_files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())),
|
||||
Encoding.UTF8);
|
||||
lock (_playerLock)
|
||||
_launches = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"writing {LaunchFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The launches noted so far, oldest first. Read again only when the file has
|
||||
/// changed: this runs every few seconds next to a running simulator.
|
||||
/// </summary>
|
||||
private IReadOnlyList<TapeLaunch> Launches()
|
||||
{
|
||||
lock (_playerLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(LaunchFile))
|
||||
return Array.Empty<TapeLaunch>();
|
||||
|
||||
var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile));
|
||||
if (_launches is null || at != _launchesAt)
|
||||
{
|
||||
_launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8));
|
||||
_launchesAt = at;
|
||||
}
|
||||
return _launches;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {LaunchFile}", ex);
|
||||
return Array.Empty<TapeLaunch>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/.</summary>
|
||||
private string? LogOf(TapeLaunch launch)
|
||||
{
|
||||
var logs = new List<string> { _paths.FromRoot("log.txt") };
|
||||
string kept = _paths.FromRoot("logs");
|
||||
if (_files.DirectoryExists(kept))
|
||||
logs.AddRange(_files.GetFiles(kept, "log (*.txt"));
|
||||
|
||||
return logs.Where(_files.FileExists)
|
||||
.Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log)))
|
||||
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the player has been in this launch. Only what the log has gained since
|
||||
/// the last look is read: this runs every few seconds next to a running simulator.
|
||||
/// </summary>
|
||||
private PlayerVehicles PlayerIn(TapeLaunch launch)
|
||||
{
|
||||
lock (_playerLock)
|
||||
{
|
||||
string? path = null;
|
||||
try
|
||||
{
|
||||
path = LogOf(launch);
|
||||
if (path is null)
|
||||
return PlayerVehicles.Unknown;
|
||||
|
||||
long size = _files.GetFileSize(path);
|
||||
if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead)
|
||||
{
|
||||
_player = PlayerVehicles.None;
|
||||
_playerLaunch = launch.LaunchedUtc;
|
||||
_playerLog = path;
|
||||
_playerLogRead = 0;
|
||||
}
|
||||
if (size == _playerLogRead)
|
||||
return _player;
|
||||
|
||||
using var stream = _files.OpenRead(path);
|
||||
stream.Seek(_playerLogRead, SeekOrigin.Begin);
|
||||
using var added = new MemoryStream();
|
||||
stream.CopyTo(added);
|
||||
byte[] bytes = added.ToArray();
|
||||
|
||||
// A line still being written waits for the next look.
|
||||
int end = Array.LastIndexOf(bytes, (byte)'\n') + 1;
|
||||
if (end > 0)
|
||||
{
|
||||
_player = _player.After(LegacyText.Decode(bytes[..end]));
|
||||
_playerLogRead += end;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {path}", ex);
|
||||
}
|
||||
return _player;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one.</summary>
|
||||
public string? DrivenVehicle =>
|
||||
Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null;
|
||||
|
||||
public TapeContext ContextOf(TapeFile file)
|
||||
{
|
||||
var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
|
||||
if (launch is null)
|
||||
return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
|
||||
|
||||
var vehicle = launch.Find(file.Vehicle);
|
||||
string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName;
|
||||
return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle),
|
||||
string.IsNullOrWhiteSpace(scenery) ? null : scenery,
|
||||
launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train);
|
||||
}
|
||||
|
||||
/// <summary>Every tape, session by session.</summary>
|
||||
public IReadOnlyList<TapeFile> List() => Sessions().SelectMany(session => session.Tapes).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The runs the tapes came from, the running or latest one first. A tape belongs
|
||||
/// to the last launch before it was written; one written before any known launch
|
||||
/// goes with those written about the same time, as a run writes them all at its end.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TapeSession> Sessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launches = Launches();
|
||||
var tapes = _files.DirectoryExists(Folder)
|
||||
? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
|
||||
: new List<TapeFile>();
|
||||
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 sessions = new List<TapeSession>();
|
||||
foreach (var launch in launches)
|
||||
{
|
||||
bool last = ReferenceEquals(launch, launches[^1]);
|
||||
bool running = last && _simulatorRunning();
|
||||
if (!running && !behind.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) })));
|
||||
}
|
||||
|
||||
var run = new List<TapeFile>();
|
||||
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
|
||||
{
|
||||
if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
|
||||
{
|
||||
sessions.Add(Session(null, running: false, run));
|
||||
run = new List<TapeFile>();
|
||||
}
|
||||
run.Add(tape);
|
||||
}
|
||||
if (run.Count > 0)
|
||||
sessions.Add(Session(null, running: false, run));
|
||||
|
||||
return sessions.OrderByDescending(session => session.Running)
|
||||
.ThenByDescending(session => session.LastWrittenUtc)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"listing {Folder}", ex);
|
||||
return Array.Empty<TapeSession>();
|
||||
}
|
||||
}
|
||||
|
||||
private TapeFile ReadTape(string path) =>
|
||||
new(path, Path.GetFileNameWithoutExtension(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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public HaslerTape? Load(TapeFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Through OpenRead, which does not mind the simulator still writing the file.
|
||||
using var stream = _files.OpenRead(file.Path);
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||
return HaslerTape.Parse(reader.ReadToEnd());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {file.Path}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
StarterNG/Domain/Tapes/HaslerTape.cs
Normal file
129
StarterNG/Domain/Tapes/HaslerTape.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StarterNG.Domain.Tapes;
|
||||
|
||||
/// <summary>One line of the simulator's physics log, in the units a speed recorder shows.</summary>
|
||||
public readonly record struct TapeSample(
|
||||
double Time,
|
||||
double SpeedKmh,
|
||||
double DistanceM,
|
||||
double BrakePressBar,
|
||||
double PipePressBar,
|
||||
double MotorCurrentA,
|
||||
bool SecurityAlarm)
|
||||
{
|
||||
/// <summary>Above this cylinder pressure the sample counts as braking.</summary>
|
||||
public const double BrakeMarkPressBar = 0.4;
|
||||
|
||||
public bool Braking => BrakePressBar > BrakeMarkPressBar;
|
||||
|
||||
public bool Current => MotorCurrentA != 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A trip recorded by the simulator with "physicslog yes": physicslog/<vehicle>.dat,
|
||||
/// a line every 0.05 s of simulated time, read as a Hasler speed recorder tape.
|
||||
/// </summary>
|
||||
public sealed class HaslerTape
|
||||
{
|
||||
// Columns counted from the start; the security flag is counted from the end, past
|
||||
// the command, which is free text. The header says m/s and MPa, the values are km/h and bar.
|
||||
private const int TimeColumn = 0;
|
||||
private const int VelocityColumn = 1;
|
||||
private const int BrakePressColumn = 8;
|
||||
private const int PipePressColumn = 9;
|
||||
private const int MotorCurrentColumn = 10;
|
||||
private const int SecurityFromEnd = 6;
|
||||
private const int MinimumColumns = 17;
|
||||
|
||||
public HaslerTape(IReadOnlyList<TapeSample> samples)
|
||||
{
|
||||
Samples = samples;
|
||||
foreach (var s in samples)
|
||||
MaxSpeedKmh = Math.Max(MaxSpeedKmh, s.SpeedKmh);
|
||||
for (int i = 1; i < samples.Count; i++)
|
||||
if (samples[i].SpeedKmh > 0.5)
|
||||
RunningTime += samples[i].Time - samples[i - 1].Time;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TapeSample> Samples { get; }
|
||||
|
||||
public bool IsEmpty => Samples.Count == 0;
|
||||
|
||||
public double StartTime => IsEmpty ? 0 : Samples[0].Time;
|
||||
|
||||
public double Duration => IsEmpty ? 0 : Samples[^1].Time - Samples[0].Time;
|
||||
|
||||
public double DistanceM => IsEmpty ? 0 : Samples[^1].DistanceM;
|
||||
|
||||
public double MaxSpeedKmh { get; }
|
||||
|
||||
/// <summary>Seconds on the move; the rest of <see cref="Duration"/> was spent standing.</summary>
|
||||
public double RunningTime { get; }
|
||||
|
||||
public double StandingTime => Duration - RunningTime;
|
||||
|
||||
/// <summary>Mean speed while moving.</summary>
|
||||
public double AverageSpeedKmh => RunningTime > 0 ? DistanceM / RunningTime * 3.6 : 0;
|
||||
|
||||
/// <summary>Mean speed over the whole recording, stops included - the commercial speed.</summary>
|
||||
public double AverageWithStopsKmh => Duration > 0 ? DistanceM / Duration * 3.6 : 0;
|
||||
|
||||
public static HaslerTape Parse(string text)
|
||||
{
|
||||
var samples = new List<TapeSample>();
|
||||
double distance = 0;
|
||||
TapeSample? previous = null;
|
||||
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var columns = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (columns.Length < MinimumColumns
|
||||
|| !Number(columns[TimeColumn], out double time)
|
||||
|| !Number(columns[VelocityColumn], out double velocity))
|
||||
continue; // the header, or a line cut short by a simulator still writing
|
||||
|
||||
Number(columns[BrakePressColumn], out double brake);
|
||||
Number(columns[PipePressColumn], out double pipe);
|
||||
Number(columns[MotorCurrentColumn], out double current);
|
||||
bool security = columns[^SecurityFromEnd] == "1";
|
||||
|
||||
double speed = Math.Abs(velocity);
|
||||
if (previous is { } p)
|
||||
{
|
||||
if (time < p.Time)
|
||||
continue;
|
||||
distance += (p.SpeedKmh + speed) / 2 / 3.6 * (time - p.Time);
|
||||
}
|
||||
|
||||
var sample = new TapeSample(time, speed, distance, brake, pipe, current, security);
|
||||
samples.Add(sample);
|
||||
previous = sample;
|
||||
}
|
||||
|
||||
return new HaslerTape(samples);
|
||||
}
|
||||
|
||||
/// <summary>Index of the last sample at or before the given time or distance.</summary>
|
||||
public int IndexAt(double position, bool byDistance)
|
||||
{
|
||||
int lo = 0, hi = Samples.Count - 1;
|
||||
if (hi < 0)
|
||||
return -1;
|
||||
while (lo < hi)
|
||||
{
|
||||
int mid = (lo + hi + 1) / 2;
|
||||
double value = byDistance ? Samples[mid].DistanceM : Samples[mid].Time;
|
||||
if (value <= position)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
private static bool Number(string text, out double value) =>
|
||||
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
56
StarterNG/Domain/Tapes/PlayerVehicles.cs
Normal file
56
StarterNG/Domain/Tapes/PlayerVehicles.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StarterNG.Domain.Tapes;
|
||||
|
||||
/// <summary>The vehicles the player's commands went to during a run, the one used last at the end.</summary>
|
||||
public sealed class PlayerVehicles
|
||||
{
|
||||
private const string CommandMark = " received command: [";
|
||||
|
||||
/// <summary>No log of the run to go by, so a change of vehicle cannot be seen.</summary>
|
||||
public static readonly PlayerVehicles Unknown = new(Array.Empty<string>(), known: false);
|
||||
|
||||
/// <summary>A log with no command in it yet.</summary>
|
||||
public static readonly PlayerVehicles None = new(Array.Empty<string>(), known: true);
|
||||
|
||||
private readonly IReadOnlyList<string> _vehicles;
|
||||
|
||||
private PlayerVehicles(IReadOnlyList<string> vehicles, bool known)
|
||||
{
|
||||
_vehicles = vehicles;
|
||||
Known = known;
|
||||
}
|
||||
|
||||
public bool Known { get; }
|
||||
|
||||
public IReadOnlyList<string> Names => _vehicles;
|
||||
|
||||
public string? Current => _vehicles.Count > 0 ? _vehicles[^1] : null;
|
||||
|
||||
public static PlayerVehicles Of(IEnumerable<string> names) => new(names.ToList(), known: true);
|
||||
|
||||
public bool Used(string vehicle) => _vehicles.Contains(vehicle, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>What a further part of the simulator's log, in whole lines, adds to this.</summary>
|
||||
public PlayerVehicles After(string log)
|
||||
{
|
||||
var vehicles = _vehicles.ToList();
|
||||
foreach (string line in log.Split('\n'))
|
||||
{
|
||||
int mark = line.IndexOf(CommandMark, StringComparison.Ordinal);
|
||||
if (mark < 0)
|
||||
continue;
|
||||
|
||||
int stamp = line.IndexOf(']');
|
||||
string vehicle = line[(stamp < mark ? stamp + 1 : 0)..mark].Trim();
|
||||
if (vehicle.Length == 0 || string.Equals(vehicles.LastOrDefault(), vehicle, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
vehicles.RemoveAll(v => string.Equals(v, vehicle, StringComparison.OrdinalIgnoreCase));
|
||||
vehicles.Add(vehicle);
|
||||
}
|
||||
return new PlayerVehicles(vehicles, known: true);
|
||||
}
|
||||
}
|
||||
53
StarterNG/Domain/Tapes/TapeFigures.cs
Normal file
53
StarterNG/Domain/Tapes/TapeFigures.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StarterNG.Domain.Tapes;
|
||||
|
||||
/// <summary>The figures a tape is printed and read with, which no drawing is needed to work out.</summary>
|
||||
public static class TapeFigures
|
||||
{
|
||||
private static readonly double[] SpeedRanges = { 90, 100, 120, 150, 180 };
|
||||
private static readonly double[] SpeedSteps = { 10, 20, 50, 100, 200 };
|
||||
private static readonly double[] MinuteSteps = { 1, 2, 5, 10, 15, 30, 60 };
|
||||
private static readonly double[] KmSteps = { 0.1, 0.2, 0.5, 1, 2, 5, 10, 20 };
|
||||
|
||||
/// <summary>The scale is printed for the speedometer's range: 90, 100, 120, 150 or 180 km/h.</summary>
|
||||
public static double TopSpeed(double maxKmh)
|
||||
{
|
||||
foreach (double range in SpeedRanges)
|
||||
if (maxKmh <= range)
|
||||
return range;
|
||||
return Math.Ceiling(maxKmh / 20) * 20;
|
||||
}
|
||||
|
||||
/// <summary>A round number of km/h between value lines, far enough apart for their figures to stay readable.</summary>
|
||||
public static int SpeedStep(double pxPerKmh) => (int)FirstThatFits(SpeedSteps, pxPerKmh, 8, 500);
|
||||
|
||||
/// <summary>A round number of minutes between clock lines, about every 60 pixels or more.</summary>
|
||||
public static double MinuteStep(double pxPerMinute) => FirstThatFits(MinuteSteps, pxPerMinute, 60, 120);
|
||||
|
||||
/// <summary>A round number of kilometres between distance marks, 50 pixels apart or more.</summary>
|
||||
public static double KmStep(double pxPerKm) => FirstThatFits(KmSteps, pxPerKm, 50, 50);
|
||||
|
||||
private static double FirstThatFits(double[] steps, double pxPerUnit, double minPx, double otherwise)
|
||||
{
|
||||
foreach (double step in steps)
|
||||
if (step * pxPerUnit >= minPx)
|
||||
return step;
|
||||
return otherwise;
|
||||
}
|
||||
|
||||
/// <summary>Hours only when there are any: 7:05, 1:02:03. Below zero counts as zero.</summary>
|
||||
public static string Clock(double seconds)
|
||||
{
|
||||
var t = TimeSpan.FromSeconds(Math.Max(0, seconds));
|
||||
return t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}";
|
||||
}
|
||||
|
||||
/// <summary>A time of day as typed; anything that is not one is midnight.</summary>
|
||||
public static TimeSpan ParseClock(string? text) =>
|
||||
TimeSpan.TryParseExact(text?.Trim(), new[] { @"h\:mm", @"hh\:mm", @"h\:mm\:ss" }, CultureInfo.InvariantCulture,
|
||||
out var clock) && clock < TimeSpan.FromDays(1)
|
||||
? clock
|
||||
: TimeSpan.Zero;
|
||||
}
|
||||
178
StarterNG/Domain/Tapes/TapeLaunch.cs
Normal file
178
StarterNG/Domain/Tapes/TapeLaunch.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StarterNG.Domain.Tapes;
|
||||
|
||||
/// <summary>How a vehicle records its runs: a Hasler-type paper tape, or an electronic recorder read out on a screen.</summary>
|
||||
public enum RecorderKind
|
||||
{
|
||||
Hasler,
|
||||
Electronic
|
||||
}
|
||||
|
||||
/// <summary>A vehicle of a launched scenery, as the simulator names it in physicslog/.</summary>
|
||||
public sealed record TapeVehicle(string Name, string DataFolder, string TypeName);
|
||||
|
||||
/// <summary>What a tape is to the one who started the simulator; in the order they are listed by.</summary>
|
||||
public enum TapeRole
|
||||
{
|
||||
Other,
|
||||
OwnConsist,
|
||||
DrivenEarlier,
|
||||
StartedIn,
|
||||
Driven
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the starter knew when it launched the simulator and the log does not
|
||||
/// say: the time of day the scenery starts at - the log only counts seconds from
|
||||
/// there - which vehicle stands behind each name the logs are written under,
|
||||
/// which of them the player went off in: every manned vehicle leaves a log - the
|
||||
/// scenario by its name and the number of the train the timetable gives. Once
|
||||
/// the next launch is noted, also where the player has been.
|
||||
/// </summary>
|
||||
public sealed record TapeLaunch(DateTime LaunchedUtc, string Scenery, TimeSpan StartClock,
|
||||
IReadOnlyList<TapeVehicle> Vehicles, string? Driven = null,
|
||||
IReadOnlyList<string>? Consist = null, string? SceneryName = null,
|
||||
string? Train = null, PlayerVehicles? Player = null)
|
||||
{
|
||||
private static readonly Regex NextLaunch = new(@"(?m)^(?=launched\t)", RegexOptions.Compiled);
|
||||
|
||||
public TapeVehicle? Find(string vehicleName) =>
|
||||
Vehicles.FirstOrDefault(v => string.Equals(v.Name, vehicleName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public TapeRole RoleOf(string vehicleName) =>
|
||||
string.Equals(Driven, vehicleName, StringComparison.OrdinalIgnoreCase)
|
||||
? TapeRole.Driven
|
||||
: Consist?.Contains(vehicleName, StringComparer.OrdinalIgnoreCase) == true
|
||||
? TapeRole.OwnConsist
|
||||
: TapeRole.Other;
|
||||
|
||||
/// <summary>The role as the run went: the vehicle the player used last is the driven one, whichever they set off in.</summary>
|
||||
public TapeRole RoleOf(string vehicleName, PlayerVehicles player)
|
||||
{
|
||||
var role = RoleOf(vehicleName);
|
||||
if (!player.Known)
|
||||
return role == TapeRole.Driven ? TapeRole.StartedIn : role;
|
||||
if (player.Current is not { } current)
|
||||
return role;
|
||||
if (string.Equals(current, vehicleName, StringComparison.OrdinalIgnoreCase))
|
||||
return TapeRole.Driven;
|
||||
return role == TapeRole.Driven || player.Used(vehicleName) ? TapeRole.DrivenEarlier : role;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A log belongs to this launch if the simulator wrote it after the starter
|
||||
/// started it; the simulator truncates the log of a vehicle on every run.
|
||||
/// </summary>
|
||||
public bool Covers(DateTime logWrittenUtc) => logWrittenUtc >= LaunchedUtc - TimeSpan.FromSeconds(5);
|
||||
|
||||
// One fact a line, tab separated: names and folders may hold spaces.
|
||||
public string Format()
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append("launched\t").Append(LaunchedUtc.ToString("O", CultureInfo.InvariantCulture)).Append('\n');
|
||||
text.Append("scenery\t").Append(Scenery).Append('\n');
|
||||
text.Append("clock\t").Append(StartClock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)).Append('\n');
|
||||
if (!string.IsNullOrWhiteSpace(SceneryName))
|
||||
text.Append("name\t").Append(SceneryName).Append('\n');
|
||||
if (!string.IsNullOrWhiteSpace(Train))
|
||||
text.Append("train\t").Append(Train).Append('\n');
|
||||
foreach (var v in Vehicles)
|
||||
text.Append("vehicle\t").Append(v.Name).Append('\t').Append(v.DataFolder).Append('\t').Append(v.TypeName)
|
||||
.Append('\n');
|
||||
if (Driven is not null)
|
||||
text.Append("driven\t").Append(Driven).Append('\n');
|
||||
foreach (string name in Consist ?? Array.Empty<string>())
|
||||
text.Append("consist\t").Append(name).Append('\n');
|
||||
if (Player is { Known: true } player)
|
||||
text.AppendJoin('\t', player.Names.Prepend("played")).Append('\n');
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Every launch a file holds, oldest first.</summary>
|
||||
public static IReadOnlyList<TapeLaunch> ParseAll(string text) =>
|
||||
NextLaunch.Split(text).Select(Parse).OfType<TapeLaunch>().OrderBy(launch => launch.LaunchedUtc).ToList();
|
||||
|
||||
public static TapeLaunch? Parse(string text)
|
||||
{
|
||||
DateTime? launched = null;
|
||||
string scenery = "";
|
||||
var clock = TimeSpan.Zero;
|
||||
var vehicles = new List<TapeVehicle>();
|
||||
string? driven = null;
|
||||
var consist = new List<string>();
|
||||
string? name = null;
|
||||
string? train = null;
|
||||
PlayerVehicles? player = null;
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var parts = line.TrimEnd('\r').Split('\t');
|
||||
switch (parts[0])
|
||||
{
|
||||
case "launched" when parts.Length > 1 &&
|
||||
DateTime.TryParse(parts[1], CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var at):
|
||||
launched = at.ToUniversalTime();
|
||||
break;
|
||||
case "scenery" when parts.Length > 1:
|
||||
scenery = parts[1];
|
||||
break;
|
||||
case "clock" when parts.Length > 1:
|
||||
TimeSpan.TryParseExact(parts[1], @"h\:mm", CultureInfo.InvariantCulture, out clock);
|
||||
break;
|
||||
case "vehicle" when parts.Length > 3:
|
||||
vehicles.Add(new TapeVehicle(parts[1], parts[2], parts[3]));
|
||||
break;
|
||||
case "driven" when parts.Length > 1:
|
||||
driven = parts[1];
|
||||
break;
|
||||
case "consist" when parts.Length > 1:
|
||||
consist.Add(parts[1]);
|
||||
break;
|
||||
case "name" when parts.Length > 1:
|
||||
name = parts[1];
|
||||
break;
|
||||
case "train" when parts.Length > 1:
|
||||
train = parts[1];
|
||||
break;
|
||||
case "played":
|
||||
player = PlayerVehicles.Of(parts.Skip(1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return launched is { } when
|
||||
? new TapeLaunch(when, scenery, clock, vehicles, driven, consist, name, train, player)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Recorders
|
||||
{
|
||||
/// <summary>
|
||||
/// Vehicles with an electronic recorder in place of a Hasler, matched on the vehicle
|
||||
/// folder and type, or failing that on the name the log is written under.
|
||||
/// </summary>
|
||||
private static readonly Regex Electronic = new(
|
||||
@"(?ix) (^|[^a-z0-9])
|
||||
( en57a[lk][a-z]* | en71ak[a-z]* | en76 | en96 | en97 | ed74 | ed160 | ed161 | ed250 | ed78 | ed72a
|
||||
| ep07p | eu07a | e6act | e6acti | e4dcu | e4mcu | e4msu | e6mcu | 111e
|
||||
| x4e | traxx | vectron | griffin | dragon | husarz | elf | impuls | flirt
|
||||
| pendolino | sa1[0-9][0-9] | 22we | 27we | 31we | 34we | 35we | 36we | 45we )
|
||||
($|[^a-z0-9])",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static RecorderKind Guess(TapeVehicle? vehicle, string tapeName)
|
||||
{
|
||||
if (vehicle is not null)
|
||||
return Electronic.IsMatch(vehicle.DataFolder.Replace('\\', '/').Split('/').Last())
|
||||
|| Electronic.IsMatch(vehicle.TypeName)
|
||||
? RecorderKind.Electronic
|
||||
: RecorderKind.Hasler;
|
||||
return Electronic.IsMatch(tapeName) ? RecorderKind.Electronic : RecorderKind.Hasler;
|
||||
}
|
||||
}
|
||||
200
StarterNG/Views/Tapes/ElectronicTapeView.cs
Normal file
200
StarterNG/Views/Tapes/ElectronicTapeView.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using StarterNG.Domain.Tapes;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// A trip on the screen of an electronic recorder - no paper, no stylus, no
|
||||
/// clockwork: the speed over a labelled km/h axis, the brake pipe and cylinder
|
||||
/// pressures on a chart of their own, and the binary
|
||||
/// channels - vigilance alarm, motor current, braking - as bars in rows below,
|
||||
/// with the clock and the kilometres along the axes.
|
||||
/// </summary>
|
||||
public sealed class ElectronicTapeView : TapeView
|
||||
{
|
||||
private const double AxisRow = 22;
|
||||
private const double SpeedChart = 220;
|
||||
private const double ChartGap = 14;
|
||||
private const double PressureChart = 84;
|
||||
private const double PressureFullScaleBar = 6;
|
||||
private const double ChannelRow = 18;
|
||||
private const int Channels = 3;
|
||||
private const double LabelRepeat = 900;
|
||||
|
||||
private const double SpeedTop = AxisRow;
|
||||
private const double SpeedBottom = SpeedTop + SpeedChart;
|
||||
private const double PressureTop = SpeedBottom + ChartGap;
|
||||
private const double PressureBottom = PressureTop + PressureChart;
|
||||
private const double ChannelsTop = PressureBottom + ChartGap;
|
||||
private const double ChannelsBottom = ChannelsTop + Channels * ChannelRow;
|
||||
|
||||
private static readonly IBrush Background = new SolidColorBrush(Color.Parse("#F4F6F8"));
|
||||
private static readonly IBrush PlotBrush = Brushes.White;
|
||||
private static readonly IBrush TextBrush = new SolidColorBrush(Color.Parse("#3A4450"));
|
||||
private static readonly IBrush DimBrush = new SolidColorBrush(Color.Parse("#7B8794"));
|
||||
private static readonly IPen FramePen = new Pen(new SolidColorBrush(Color.Parse("#B9C2CC")), 1);
|
||||
private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.Parse("#E4E8ED")), 1);
|
||||
private static readonly IPen GridBoldPen = new Pen(new SolidColorBrush(Color.Parse("#CED5DD")), 1);
|
||||
private static readonly IPen SpeedPen = new Pen(new SolidColorBrush(Color.Parse("#1565C0")), 1.6,
|
||||
lineJoin: PenLineJoin.Round);
|
||||
private static readonly IPen PipePen = new Pen(new SolidColorBrush(Color.Parse("#2E7D32")), 1.3,
|
||||
lineJoin: PenLineJoin.Round);
|
||||
private static readonly IPen CylinderPen = new Pen(new SolidColorBrush(Color.Parse("#C62828")), 1.3,
|
||||
lineJoin: PenLineJoin.Round);
|
||||
private static readonly IBrush ShpBrush = new SolidColorBrush(Color.Parse("#F9A825"));
|
||||
private static readonly IBrush CurrentBrush = new SolidColorBrush(Color.Parse("#5C6BC0"));
|
||||
private static readonly IBrush BrakeBrush = new SolidColorBrush(Color.Parse("#E53935"));
|
||||
private static readonly Typeface Face = new(FontFamily.Default);
|
||||
|
||||
protected override double LeftMargin => 74;
|
||||
|
||||
protected override double TapeHeight => ChannelsBottom + AxisRow + 4;
|
||||
|
||||
private double SpeedY(double speed) => SpeedBottom - SpeedChart * Math.Min(speed, TopSpeed) / TopSpeed;
|
||||
|
||||
private static double PressureY(double bar) =>
|
||||
PressureBottom - PressureChart * Math.Clamp(bar / PressureFullScaleBar, 0, 1);
|
||||
|
||||
protected override void DrawBlank(DrawingContext context) =>
|
||||
context.DrawRectangle(Background, null, new Rect(0, 0, Bounds.Width, TapeHeight));
|
||||
|
||||
protected override void DrawTape(DrawingContext context, HaslerTape tape)
|
||||
{
|
||||
double left = LeftMargin, right = TapeRight;
|
||||
foreach (var (top, bottom) in new[] { (SpeedTop, SpeedBottom), (PressureTop, PressureBottom),
|
||||
(ChannelsTop, ChannelsBottom) })
|
||||
context.DrawRectangle(PlotBrush, FramePen, new Rect(left, top, right - left, bottom - top));
|
||||
|
||||
DrawValueGrid(context, left, right);
|
||||
DrawTimeAxis(context, tape);
|
||||
DrawDistanceAxis(context, tape);
|
||||
|
||||
context.DrawGeometry(null, PipePen, Trace(tape, s => PressureY(s.PipePressBar)));
|
||||
context.DrawGeometry(null, CylinderPen, Trace(tape, s => PressureY(s.BrakePressBar)));
|
||||
context.DrawGeometry(null, SpeedPen, Trace(tape, s => SpeedY(s.SpeedKmh)));
|
||||
|
||||
DrawChannel(context, tape, 0, s => s.SecurityAlarm, ShpBrush);
|
||||
DrawChannel(context, tape, 1, s => s.Current, CurrentBrush);
|
||||
DrawChannel(context, tape, 2, s => s.Braking, BrakeBrush);
|
||||
|
||||
DrawLegend(context);
|
||||
}
|
||||
|
||||
/// <summary>The value lines and their figures, repeated along a long trip so a scrolled view still reads.</summary>
|
||||
private void DrawValueGrid(DrawingContext context, double left, double right)
|
||||
{
|
||||
int step = TapeFigures.SpeedStep(SpeedChart / TopSpeed);
|
||||
for (int v = 0; v <= TopSpeed; v += step)
|
||||
{
|
||||
double y = SpeedY(v);
|
||||
context.DrawLine(v % (5 * step) == 0 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y));
|
||||
if (v % (2 * step) == 0)
|
||||
for (double x = left; x < right; x += LabelRepeat)
|
||||
Print(context, v.ToString(CultureInfo.InvariantCulture), x == left ? 30 : x + 4, y - 7, 10,
|
||||
x == left ? TextBrush : DimBrush);
|
||||
}
|
||||
|
||||
for (int bar = 0; bar <= PressureFullScaleBar; bar++)
|
||||
{
|
||||
double y = PressureY(bar);
|
||||
context.DrawLine(bar == 5 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y));
|
||||
if (bar % 2 == 0)
|
||||
Print(context, bar.ToString(CultureInfo.InvariantCulture), 52, y - 7, 10, TextBrush);
|
||||
}
|
||||
|
||||
for (int i = 1; i < Channels; i++)
|
||||
context.DrawLine(GridPen, new Point(left, ChannelsTop + i * ChannelRow),
|
||||
new Point(right, ChannelsTop + i * ChannelRow));
|
||||
}
|
||||
|
||||
/// <summary>The clock along the top edge: a line every minute of uniform time, or ticks where minutes pass on a distance axis.</summary>
|
||||
private void DrawTimeAxis(DrawingContext context, HaslerTape tape)
|
||||
{
|
||||
double pxPerMinute = ByDistance
|
||||
? Scale / Math.Max(tape.Duration / 60.0 / Math.Max(Length, 0.001), 0.001)
|
||||
: Scale;
|
||||
double step = TapeFigures.MinuteStep(pxPerMinute);
|
||||
double next = Math.Ceiling(ClockAt(tape.Samples[0]) / 60 / step) * step * 60;
|
||||
double lastLabel = double.NegativeInfinity;
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
double clock = ClockAt(s);
|
||||
if (clock < next)
|
||||
continue;
|
||||
double x = XOf(s);
|
||||
context.DrawLine(GridPen, new Point(x, SpeedTop), new Point(x, SpeedBottom));
|
||||
context.DrawLine(FramePen, new Point(x, SpeedTop - 4), new Point(x, SpeedTop));
|
||||
if (x - lastLabel > 46)
|
||||
{
|
||||
var t = TimeSpan.FromSeconds(next % 86400);
|
||||
Print(context, $"{(int)t.TotalHours:00}:{t.Minutes:00}", x - 14, 3, 10, TextBrush);
|
||||
lastLabel = x;
|
||||
}
|
||||
next = (Math.Floor(clock / 60 / step) + 1) * step * 60;
|
||||
}
|
||||
Print(context, "h:min", 4, 3, 10, DimBrush);
|
||||
}
|
||||
|
||||
/// <summary>Kilometres along the bottom edge.</summary>
|
||||
private void DrawDistanceAxis(DrawingContext context, HaslerTape tape)
|
||||
{
|
||||
double stepKm = TapeFigures.KmStep(ByDistance ? Scale : Scale * tape.Duration / 60 / Math.Max(tape.DistanceM / 1000, 0.001));
|
||||
double next = 0;
|
||||
double y = ChannelsBottom;
|
||||
double lastLabel = double.NegativeInfinity;
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
if (s.DistanceM < next * 1000)
|
||||
continue;
|
||||
double x = XOf(s);
|
||||
context.DrawLine(FramePen, new Point(x, y), new Point(x, y + 4));
|
||||
if (ByDistance)
|
||||
context.DrawLine(GridPen, new Point(x, PressureTop), new Point(x, PressureBottom));
|
||||
if (x - lastLabel > 40)
|
||||
{
|
||||
Print(context, next.ToString("0.#", CultureInfo.InvariantCulture), x - 6, y + 5, 10, TextBrush);
|
||||
lastLabel = x;
|
||||
}
|
||||
next = (Math.Floor(s.DistanceM / 1000 / stepKm) + 1) * stepKm;
|
||||
}
|
||||
Print(context, "km", 4, y + 5, 10, DimBrush);
|
||||
}
|
||||
|
||||
private void DrawChannel(DrawingContext context, HaslerTape tape, int row, Func<TapeSample, bool> on, IBrush brush)
|
||||
{
|
||||
double top = ChannelsTop + row * ChannelRow + 4, height = ChannelRow - 8;
|
||||
double? start = null, last = LeftMargin;
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
double x = XOf(s);
|
||||
if (on(s))
|
||||
start ??= x;
|
||||
else if (start is { } from)
|
||||
{
|
||||
context.DrawRectangle(brush, null, new Rect(from, top, Math.Max(1, x - from), height));
|
||||
start = null;
|
||||
}
|
||||
last = x;
|
||||
}
|
||||
if (start is { } open)
|
||||
context.DrawRectangle(brush, null, new Rect(open, top, Math.Max(1, last.Value - open), height));
|
||||
}
|
||||
|
||||
private void DrawLegend(DrawingContext context)
|
||||
{
|
||||
Print(context, "km/h", 4, SpeedTop + 2, 10, DimBrush);
|
||||
Print(context, "bar", 4, PressureTop + 2, 10, DimBrush);
|
||||
Print(context, App.Loc["HaslerTrackPipe"], 4, PressureTop + 22, 10, PipePen.Brush!);
|
||||
Print(context, App.Loc["HaslerTrackCylinders"], 4, PressureTop + 38, 10, CylinderPen.Brush!);
|
||||
Print(context, App.Loc["HaslerTrackShp"], 4, ChannelsTop + 2, 10, TextBrush);
|
||||
Print(context, App.Loc["HaslerTrackCurrent"], 4, ChannelsTop + ChannelRow + 2, 10, TextBrush);
|
||||
Print(context, App.Loc["HaslerTrackBrake"], 4, ChannelsTop + 2 * ChannelRow + 2, 10, TextBrush);
|
||||
}
|
||||
|
||||
private static void Print(DrawingContext context, string text, double x, double y, double size, IBrush brush) =>
|
||||
context.DrawText(new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, Face, size,
|
||||
brush), new Point(x, y));
|
||||
}
|
||||
359
StarterNG/Views/Tapes/HaslerTapeView.cs
Normal file
359
StarterNG/Views/Tapes/HaslerTapeView.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using StarterNG.Domain.Tapes;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// A trip drawn as a Hasler tape: blue-grey waxed paper, on which every record is
|
||||
/// a light, slightly fuzzy line.
|
||||
/// <list type="bullet">
|
||||
/// <item>Top margin: a hole every 0.5 km, with the full hours punched in dots between them.</item>
|
||||
/// <item>One field ruled every 10 km/h. The speed curve runs in the lower part and its
|
||||
/// figures sit on their lines across the paper. The minute stylus zig-zags in the
|
||||
/// upper part on a 10 minute cycle, peaking on every tenth minute. While the train
|
||||
/// stands it keeps going, so a stop on a distance-fed tape shows as a brush of strokes.</item>
|
||||
/// <item>Two red lines. Every vigilance (SHP) acknowledgement is a stroke up through the upper one.</item>
|
||||
/// <item>Motor current: a step of its own stylus.</item>
|
||||
/// <item>Brake cylinder pressure: spikes rising from a baseline near the bottom edge.</item>
|
||||
/// </list>
|
||||
/// The paper is fed by distance or by time.
|
||||
/// </summary>
|
||||
public sealed class HaslerTapeView : TapeView
|
||||
{
|
||||
public HaslerTapeView() => RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.LowQuality);
|
||||
|
||||
private const double Pitch = 16; // px between printed lines, one per 10 km/h
|
||||
private const int MinuteLines = 6; // the minute stylus swings over this many lines
|
||||
|
||||
private const double TopEdge = 26;
|
||||
private const double FieldTop = TopEdge + 10;
|
||||
private const double ShpGap = 14;
|
||||
private const double ShpReach = 9;
|
||||
private const double RedGap = 22;
|
||||
private const double CurrentBand = 16;
|
||||
private const double BrakeBand = 46;
|
||||
private const double BottomEdge = 20;
|
||||
|
||||
private const double MinuteCycle = 600; // s, from one peak to the next
|
||||
private const double PerforationStepM = 500;
|
||||
private const double BrakeFullScaleBar = 5;
|
||||
private const double LabelSpacing = 170;
|
||||
|
||||
private const int GrainSize = 512;
|
||||
private const int GrainPatch = 256; // px laid at a time, each from its own place in the grain
|
||||
private const int MottlingCell = 8; // px of paper to a pixel of the mottling
|
||||
|
||||
private static readonly IBrush PaperBrush = new SolidColorBrush(Color.Parse("#7A94A5"));
|
||||
private static readonly Bitmap Grain = GrainTexture();
|
||||
private static Bitmap? _mottling;
|
||||
private static readonly IBrush EdgeBrush = new SolidColorBrush(Color.Parse("#A8B9C3"));
|
||||
private static readonly IBrush PrintBrush = new SolidColorBrush(Color.Parse("#2A2B33"));
|
||||
private static readonly IBrush HoleBrush = new SolidColorBrush(Color.Parse("#EEF3F5"));
|
||||
private static readonly IPen PrintPen = new Pen(PrintBrush, 1);
|
||||
private static readonly IPen EdgePen = new Pen(PrintBrush, 1.2);
|
||||
private static readonly IPen RedPen = new Pen(new SolidColorBrush(Color.Parse("#9C4E63")), 4);
|
||||
private static readonly IPen StylusPen = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.9), 1.5,
|
||||
lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round);
|
||||
private static readonly IPen StylusHalo = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.22), 3.6,
|
||||
lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round);
|
||||
private static readonly Typeface PrintFace = new(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold);
|
||||
|
||||
// 5x7 dot figures for the hours punched into the top edge.
|
||||
private static readonly string[] Digits =
|
||||
{
|
||||
"01110100011001110101110011000101110", "00100011000010000100001000010001110",
|
||||
"01110100010000100010001000100011111", "11111000100010000010000011000101110",
|
||||
"00010001100101010010111110001000010", "11111100001111000001000011000101110",
|
||||
"00110010001000011110100011000101110", "11111000010001000100010000100001000",
|
||||
"01110100011000101110100011000101110", "01110100011000101111000010001001100"
|
||||
};
|
||||
|
||||
protected override double LeftMargin => 8;
|
||||
|
||||
private int SpeedLines => (int)(TopSpeed / 10);
|
||||
|
||||
private double FieldBottom => FieldTop + (SpeedLines + MinuteLines) * Pitch;
|
||||
|
||||
private double RedTop => FieldBottom + ShpGap;
|
||||
|
||||
private double CurrentTop => RedTop + RedGap + 4;
|
||||
|
||||
private double BrakeBase => CurrentTop + CurrentBand + BrakeBand;
|
||||
|
||||
protected override double TapeHeight => BrakeBase + 6 + BottomEdge;
|
||||
|
||||
private double SpeedY(double speed) => FieldBottom - Math.Min(speed, TopSpeed) / 10 * Pitch;
|
||||
|
||||
/// <summary>The minute stylus: at the top on every tenth minute, at the bottom five minutes later.</summary>
|
||||
private static double MinuteY(double clock)
|
||||
{
|
||||
double phase = clock % MinuteCycle / MinuteCycle;
|
||||
return FieldTop + MinuteLines * Pitch * (1 - Math.Abs(1 - 2 * phase));
|
||||
}
|
||||
|
||||
private double BrakeY(double press) => BrakeBase - BrakeBand * Math.Clamp(press / BrakeFullScaleBar, 0, 1);
|
||||
|
||||
protected override void DrawBlank(DrawingContext context)
|
||||
{
|
||||
double width = Bounds.Width;
|
||||
double height = TapeHeight;
|
||||
DrawPaper(context, width, height);
|
||||
context.DrawRectangle(EdgeBrush, null, new Rect(0, 0, width, TopEdge));
|
||||
context.DrawRectangle(EdgeBrush, null, new Rect(0, height - BottomEdge, width, BottomEdge));
|
||||
context.DrawLine(EdgePen, new Point(0, height - BottomEdge), new Point(width, height - BottomEdge));
|
||||
}
|
||||
|
||||
protected override void DrawTape(DrawingContext context, HaslerTape tape)
|
||||
{
|
||||
DrawPrint(context, Bounds.Width);
|
||||
DrawEdges(context, tape, TapeHeight);
|
||||
DrawStylus(context, tape, s => MinuteY(ClockAt(s)));
|
||||
DrawStylus(context, tape, s => SpeedY(s.SpeedKmh));
|
||||
DrawShp(context, tape);
|
||||
DrawStylus(context, tape, s => s.Current ? CurrentTop + CurrentBand - 4 : CurrentTop + 3);
|
||||
DrawStylus(context, tape, s => BrakeY(s.BrakePressBar));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the paper comes printed with: a line every 10 km/h across the whole
|
||||
/// field, the speed figures climbing across it - each on its own line, which
|
||||
/// breaks around it - and the two red lines.
|
||||
/// </summary>
|
||||
private void DrawPrint(DrawingContext context, double width)
|
||||
{
|
||||
int lines = SpeedLines + MinuteLines;
|
||||
var gaps = new System.Collections.Generic.List<(int Line, double From, double To, FormattedText Text)>();
|
||||
double block = SpeedLines * LabelSpacing;
|
||||
for (double start = LeftMargin + LabelSpacing / 2; start < width; start += block)
|
||||
for (int v = 1; v <= SpeedLines; v++)
|
||||
{
|
||||
double x = start + (v - 1) * LabelSpacing;
|
||||
if (x >= width)
|
||||
break;
|
||||
var text = new FormattedText((v * 10).ToString(CultureInfo.InvariantCulture),
|
||||
CultureInfo.InvariantCulture, FlowDirection.LeftToRight, PrintFace, 15,
|
||||
PrintBrush);
|
||||
gaps.Add((v, x - 5, x + text.Width + 5, text));
|
||||
}
|
||||
|
||||
for (int line = 0; line <= lines; line++)
|
||||
{
|
||||
double y = FieldBottom - line * Pitch;
|
||||
double from = 0;
|
||||
foreach (var gap in gaps)
|
||||
if (gap.Line == line)
|
||||
{
|
||||
context.DrawLine(PrintPen, new Point(from, y), new Point(gap.From, y));
|
||||
from = gap.To;
|
||||
}
|
||||
context.DrawLine(PrintPen, new Point(from, y), new Point(width, y));
|
||||
}
|
||||
|
||||
foreach (var gap in gaps)
|
||||
context.DrawText(gap.Text, new Point(gap.From + 5, SpeedY(gap.Line * 10) - gap.Text.Height / 2));
|
||||
|
||||
context.DrawLine(RedPen, new Point(0, RedTop), new Point(width, RedTop));
|
||||
context.DrawLine(RedPen, new Point(0, RedTop + RedGap), new Point(width, RedTop + RedGap));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transport holes along both edges, one per 0.5 km, and the hour punched
|
||||
/// as a figure of dots into the top edge where the clock passes a full hour -
|
||||
/// midnight as 24.
|
||||
/// </summary>
|
||||
private void DrawEdges(DrawingContext context, HaslerTape tape, double height)
|
||||
{
|
||||
var figures = new System.Collections.Generic.List<(double From, double To)>();
|
||||
double nextHour = Math.Ceiling(ClockAt(tape.Samples[0]) / 3600) * 3600;
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
double clock = ClockAt(s);
|
||||
if (clock >= nextHour)
|
||||
{
|
||||
int hour = (int)(clock / 3600) % 24;
|
||||
figures.Add(PunchHour(context, hour == 0 ? 24 : hour, XOf(s)));
|
||||
nextHour = (Math.Floor(clock / 3600) + 1) * 3600;
|
||||
}
|
||||
}
|
||||
|
||||
double nextHole = 0;
|
||||
foreach (var s in tape.Samples)
|
||||
if (s.DistanceM >= nextHole)
|
||||
{
|
||||
double x = XOf(s);
|
||||
if (!figures.Exists(f => x > f.From - 3 && x < f.To + 3))
|
||||
Hole(context, x, TopEdge / 2);
|
||||
Hole(context, x, height - BottomEdge / 2);
|
||||
nextHole = (Math.Floor(s.DistanceM / PerforationStepM) + 1) * PerforationStepM;
|
||||
}
|
||||
}
|
||||
|
||||
private static (double From, double To) PunchHour(DrawingContext context, int hour, double x)
|
||||
{
|
||||
const double dot = 3;
|
||||
string figures = hour.ToString(CultureInfo.InvariantCulture);
|
||||
double width = figures.Length * 6 * dot;
|
||||
double left = x - width / 2;
|
||||
double top = (TopEdge - 7 * dot) / 2 + dot / 2;
|
||||
for (int f = 0; f < figures.Length; f++)
|
||||
{
|
||||
string pattern = Digits[figures[f] - '0'];
|
||||
for (int i = 0; i < pattern.Length; i++)
|
||||
if (pattern[i] == '1')
|
||||
context.DrawEllipse(HoleBrush, null,
|
||||
new Point(left + f * 6 * dot + i % 5 * dot, top + i / 5 * dot), 1.2, 1.2);
|
||||
}
|
||||
return (left, left + width);
|
||||
}
|
||||
|
||||
private static void Hole(DrawingContext context, double x, double y)
|
||||
{
|
||||
context.DrawEllipse(HoleBrush, null, new Point(x, y), 2.4, 2.4);
|
||||
context.DrawEllipse(HoleBrush, null, new Point(x - 2.8, y), 0.9, 0.9);
|
||||
context.DrawEllipse(HoleBrush, null, new Point(x + 2.8, y), 0.9, 0.9);
|
||||
}
|
||||
|
||||
private void DrawStylus(DrawingContext context, HaslerTape tape, Func<TapeSample, double> y)
|
||||
{
|
||||
var geometry = Trace(tape, y);
|
||||
context.DrawGeometry(null, StylusHalo, geometry);
|
||||
context.DrawGeometry(null, StylusPen, geometry);
|
||||
}
|
||||
|
||||
/// <summary>The vigilance stylus rests under the upper red line and strokes up through it on every acknowledgement.</summary>
|
||||
private void DrawShp(DrawingContext context, HaslerTape tape)
|
||||
{
|
||||
double rest = RedTop + 6;
|
||||
double left = LeftMargin, right = TapeRight;
|
||||
context.DrawLine(StylusHalo, new Point(left, rest), new Point(right, rest));
|
||||
context.DrawLine(StylusPen, new Point(left, rest), new Point(right, rest));
|
||||
bool alarm = false;
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
if (alarm && !s.SecurityAlarm)
|
||||
{
|
||||
double x = XOf(s);
|
||||
context.DrawLine(StylusHalo, new Point(x, rest), new Point(x, RedTop - ShpReach));
|
||||
context.DrawLine(StylusPen, new Point(x, rest), new Point(x, RedTop - ShpReach));
|
||||
}
|
||||
alarm = s.SecurityAlarm;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paper, which repeats nowhere: the mottling is one bitmap stretched over the
|
||||
/// whole tape, and the grain, having no shapes to join up, is laid in patches from
|
||||
/// scattered places of its texture. A tiled ImageBrush loses tiles on screen, leaving
|
||||
/// holes in the paper; plain DrawImage calls do not, and the solid colour underneath
|
||||
/// covers anything the bitmaps do not.
|
||||
/// </summary>
|
||||
private static void DrawPaper(DrawingContext context, double width, double height)
|
||||
{
|
||||
context.DrawRectangle(PaperBrush, null, new Rect(0, 0, width, height));
|
||||
context.DrawImage(MottlingFor(width, height), new Rect(0, 0, width / MottlingCell, height / MottlingCell),
|
||||
new Rect(0, 0, width, height));
|
||||
|
||||
for (int row = 0; row * GrainPatch < height; row++)
|
||||
for (int column = 0; column * GrainPatch < width; column++)
|
||||
{
|
||||
uint place = Hash(column, row);
|
||||
// The last row and column are cut to the tape, not left hanging over its edge.
|
||||
var size = new Size(Math.Min(GrainPatch, width - column * GrainPatch),
|
||||
Math.Min(GrainPatch, height - row * GrainPatch));
|
||||
context.DrawImage(Grain, new Rect(new Point(place % GrainPatch, (place >> 16) % GrainPatch), size),
|
||||
new Rect(new Point(column * GrainPatch, row * GrainPatch), size));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The light and dark clouds of the coating. Worked out from the place on the paper
|
||||
/// alone, so one bitmap serves every tape it is big enough for and only ever grows.
|
||||
/// </summary>
|
||||
private static Bitmap MottlingFor(double width, double height)
|
||||
{
|
||||
int columns = (int)Math.Ceiling(width / MottlingCell) + 1;
|
||||
int rows = (int)Math.Ceiling(height / MottlingCell) + 1;
|
||||
if (_mottling is not null && _mottling.PixelSize.Width >= columns && _mottling.PixelSize.Height >= rows)
|
||||
return _mottling;
|
||||
|
||||
columns = Math.Max(columns + columns / 4, _mottling?.PixelSize.Width ?? 0);
|
||||
rows = Math.Max(rows, _mottling?.PixelSize.Height ?? 0);
|
||||
var pixels = new byte[columns * rows * 4];
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < columns; x++)
|
||||
{
|
||||
double shade = Noise(x, y, 5) * 18 + Noise(x, y, 23) * 12;
|
||||
int o = (y * columns + x) * 4;
|
||||
pixels[o] = Channel(165 + shade); // B
|
||||
pixels[o + 1] = Channel(148 + shade); // G
|
||||
pixels[o + 2] = Channel(122 + shade); // R
|
||||
pixels[o + 3] = 255;
|
||||
}
|
||||
|
||||
return _mottling = ToBitmap(pixels, columns, rows, AlphaFormat.Opaque);
|
||||
}
|
||||
|
||||
/// <summary>The fine grain of the coating and flecks of it already gone, laid over the mottling.</summary>
|
||||
private static Bitmap GrainTexture()
|
||||
{
|
||||
var random = new Random(2020);
|
||||
var pixels = new byte[GrainSize * GrainSize * 4];
|
||||
for (int o = 0; o < pixels.Length; o += 4)
|
||||
{
|
||||
double shade = (random.NextDouble() - 0.5) * 20;
|
||||
if (random.NextDouble() < 0.004)
|
||||
shade += 64; // a fleck of coating already gone
|
||||
|
||||
// Premultiplied: white to lighten, black to darken.
|
||||
byte alpha = Channel(Math.Abs(shade));
|
||||
pixels[o] = pixels[o + 1] = pixels[o + 2] = shade > 0 ? alpha : (byte)0;
|
||||
pixels[o + 3] = alpha;
|
||||
}
|
||||
|
||||
return ToBitmap(pixels, GrainSize, GrainSize, AlphaFormat.Premul);
|
||||
}
|
||||
|
||||
/// <summary>Smooth noise over cells of the given size, -0.5 to 0.5; a place always gives the same value.</summary>
|
||||
private static double Noise(int x, int y, int cell)
|
||||
{
|
||||
int left = x / cell, top = y / cell;
|
||||
double across = Fade((double)(x % cell) / cell), down = Fade((double)(y % cell) / cell);
|
||||
return Lerp(Lerp(Corner(left, top), Corner(left + 1, top), across),
|
||||
Lerp(Corner(left, top + 1), Corner(left + 1, top + 1), across), down);
|
||||
|
||||
double Corner(int i, int j) => Hash(i, j + cell * 7919) / (double)uint.MaxValue - 0.5;
|
||||
static double Fade(double t) => t * t * (3 - 2 * t);
|
||||
static double Lerp(double a, double b, double t) => a + (b - a) * t;
|
||||
}
|
||||
|
||||
private static uint Hash(int x, int y)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)x * 73856093u ^ (uint)y * 19349663u;
|
||||
h ^= h >> 15;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13;
|
||||
h *= 3266489917u;
|
||||
return h ^ h >> 16;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte Channel(double v) => (byte)Math.Clamp(v, 0, 255);
|
||||
|
||||
private static Bitmap ToBitmap(byte[] pixels, int width, int height, AlphaFormat alpha)
|
||||
{
|
||||
var bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), PixelFormat.Bgra8888, alpha);
|
||||
using (var buffer = bitmap.Lock())
|
||||
for (int y = 0; y < height; y++)
|
||||
Marshal.Copy(pixels, y * width * 4, buffer.Address + y * buffer.RowBytes, width * 4);
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
566
StarterNG/Views/Tapes/HaslerTapeWindow.cs
Normal file
566
StarterNG/Views/Tapes/HaslerTapeWindow.cs
Normal file
@@ -0,0 +1,566 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Domain.Tapes;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the speed recorder tapes the simulator writes to physicslog/ - one per
|
||||
/// driven vehicle - grouped by the run they came from, and lays them out as Hasler
|
||||
/// paper, fed by distance, or as the screen of an electronic recorder for vehicles
|
||||
/// that have one, by time or by distance. The clock and the recorder come from
|
||||
/// what the starter noted at the launch; both can be set by hand. While a session
|
||||
/// runs "Live" goes to the tape of the vehicle the player is in and keeps
|
||||
/// re-reading it.
|
||||
/// </summary>
|
||||
public sealed class HaslerTapeWindow : Window
|
||||
{
|
||||
private const double DefaultKmScale = 40;
|
||||
private const double DefaultMinuteScale = 30;
|
||||
private const double MaxExportWidth = 32000;
|
||||
|
||||
private static readonly IBrush RunningBrush = new SolidColorBrush(Color.Parse("#41c400"));
|
||||
|
||||
private readonly HaslerTapes _tapes = AppServices.Current.Tapes;
|
||||
private readonly TreeView _list = new() { Width = 260 };
|
||||
private readonly ComboBox _feed = new() { Width = 120, VerticalAlignment = VerticalAlignment.Center };
|
||||
private readonly Slider _scale = new() { Width = 140, Minimum = 4, Maximum = 600, VerticalAlignment = VerticalAlignment.Center };
|
||||
private readonly ComboBox _recorder = new() { Width = 150, VerticalAlignment = VerticalAlignment.Center };
|
||||
private readonly CheckBox _live = new() { VerticalAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox _startClock = new() { Width = 64, PlaceholderText = "hh:mm", VerticalAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBlock _summary = new() { Margin = new Thickness(8, 4), TextWrapping = TextWrapping.Wrap };
|
||||
private readonly TextBlock _cursor = new() { Margin = new Thickness(8, 4), FontSize = 12, Opacity = 0.85 };
|
||||
private readonly TextBlock _empty = new()
|
||||
{
|
||||
Margin = new Thickness(24),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Opacity = 0.75,
|
||||
IsVisible = false
|
||||
};
|
||||
private readonly ScrollViewer _scroll;
|
||||
private TapeView _view;
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) };
|
||||
private TapeContext? _context;
|
||||
private readonly Control _recordingOff;
|
||||
private readonly Control _logOff;
|
||||
private readonly Dictionary<DateTime, bool> _expanded = new();
|
||||
|
||||
private TapeFile? _shown;
|
||||
private string? _followed;
|
||||
private string _listed = "";
|
||||
private bool _running;
|
||||
private bool _loading;
|
||||
private bool _looking;
|
||||
|
||||
/// <summary>A setting went on from here; the settings page's boxes have to follow it.</summary>
|
||||
public event Action? LogEnabled;
|
||||
|
||||
public HaslerTapeWindow()
|
||||
{
|
||||
Title = App.Loc["HaslerTapes"];
|
||||
Width = 1100;
|
||||
Height = 560;
|
||||
MinWidth = 700;
|
||||
MinHeight = 420;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
|
||||
_feed.ItemsSource = new[] { App.Loc["HaslerByDistance"], App.Loc["HaslerByTime"] };
|
||||
_feed.SelectedIndex = 0;
|
||||
_feed.IsEnabled = false;
|
||||
_feed.SelectionChanged += (_, _) => FeedChanged();
|
||||
_scale.Value = DefaultKmScale;
|
||||
_scale.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == RangeBase.ValueProperty)
|
||||
_view.Scale = _scale.Value;
|
||||
};
|
||||
_live.Content = App.Loc["HaslerLive"];
|
||||
ToolTip.SetShowOnDisabled(_live, true);
|
||||
_live.IsCheckedChanged += async (_, _) =>
|
||||
{
|
||||
if (_live.IsChecked == true)
|
||||
await RefreshListAsync(toDriven: true);
|
||||
};
|
||||
_timer.Tick += async (_, _) => await LookAtSessionsAsync();
|
||||
_startClock.TextChanged += (_, _) => _view.StartClock = TapeFigures.ParseClock(_startClock.Text);
|
||||
_recorder.ItemsSource = new[] { App.Loc["HaslerRecorderHasler"], App.Loc["HaslerRecorderElectronic"] };
|
||||
_recorder.SelectedIndex = 0;
|
||||
_recorder.SelectionChanged += (_, _) => RecorderChanged();
|
||||
ToolTip.SetTip(_startClock, App.Loc["HaslerStartClockTip"]);
|
||||
|
||||
ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled);
|
||||
_list.SelectionChanged += async (_, _) =>
|
||||
{
|
||||
// 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;
|
||||
var file = _list.SelectedItem switch
|
||||
{
|
||||
TreeViewItem { Tag: TapeFile tape } => tape,
|
||||
TreeViewItem { Tag: TapeSession { Tapes.Count: > 0 } run } => run.Tapes[0],
|
||||
_ => null
|
||||
};
|
||||
if (file is null || file == _shown)
|
||||
return;
|
||||
|
||||
ApplyContext(_tapes.ContextOf(file));
|
||||
await ShowAsync(file, scrollToEnd: false);
|
||||
};
|
||||
|
||||
_view = NewView(RecorderKind.Hasler);
|
||||
_view.Scale = DefaultKmScale;
|
||||
_view.Hovered += ShowCursor;
|
||||
_scroll = new ScrollViewer
|
||||
{
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
Content = _view
|
||||
};
|
||||
|
||||
_recordingOff = Strip("HaslerRecordingOff", "HaslerRecordingEnable", settings => settings.PhysicsLog = true);
|
||||
_logOff = Strip("HaslerLogOff", "HaslerLogEnable", settings => settings.DebugLog = true);
|
||||
|
||||
var folder = ToolButton(App.Loc["HaslerOpenFolder"]);
|
||||
folder.Click += (_, _) =>
|
||||
{
|
||||
AppServices.Current.Files.CreateDirectory(_tapes.Folder);
|
||||
AppServices.Current.Processes.OpenInShell(_tapes.Folder);
|
||||
};
|
||||
|
||||
var export = ToolButton(App.Loc["HaslerExportPng"]);
|
||||
export.Click += async (_, _) => await ExportPngAsync();
|
||||
|
||||
var toolbar = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4, 2) };
|
||||
foreach (var control in new Control[]
|
||||
{
|
||||
Label(App.Loc["HaslerRecorder"]), _recorder, Label(App.Loc["HaslerFeed"]), _feed,
|
||||
Label(App.Loc["HaslerScale"]), _scale,
|
||||
Label(App.Loc["HaslerStartClock"]), _startClock, _live, folder, export
|
||||
})
|
||||
{
|
||||
control.Margin = new Thickness(4);
|
||||
toolbar.Children.Add(control);
|
||||
}
|
||||
|
||||
var tapeArea = new DockPanel();
|
||||
DockPanel.SetDock(_recordingOff, Dock.Top);
|
||||
DockPanel.SetDock(_logOff, Dock.Top);
|
||||
DockPanel.SetDock(toolbar, Dock.Top);
|
||||
DockPanel.SetDock(_summary, Dock.Top);
|
||||
DockPanel.SetDock(_cursor, Dock.Bottom);
|
||||
tapeArea.Children.Add(_recordingOff);
|
||||
tapeArea.Children.Add(_logOff);
|
||||
tapeArea.Children.Add(toolbar);
|
||||
tapeArea.Children.Add(_summary);
|
||||
tapeArea.Children.Add(_cursor);
|
||||
tapeArea.Children.Add(new Panel { Children = { _scroll, _empty } });
|
||||
|
||||
var root = new DockPanel();
|
||||
var listBorder = new Border
|
||||
{
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#3A424A")),
|
||||
BorderThickness = new Thickness(0, 0, 1, 0),
|
||||
Child = _list
|
||||
};
|
||||
DockPanel.SetDock(listBorder, Dock.Left);
|
||||
root.Children.Add(listBorder);
|
||||
root.Children.Add(tapeArea);
|
||||
Content = root;
|
||||
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
_timer.Start();
|
||||
await RefreshListAsync();
|
||||
};
|
||||
Activated += (_, _) => ShowWhatIsKnown();
|
||||
Closed += (_, _) => _timer.Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What gets recorded, and whether there is a trip for "live" to follow: a session
|
||||
/// that runs, and the player in a vehicle.
|
||||
/// </summary>
|
||||
private void ShowWhatIsKnown()
|
||||
{
|
||||
var settings = AppServices.Current.Settings;
|
||||
_recordingOff.IsVisible = !settings.PhysicsLog;
|
||||
_logOff.IsVisible = settings.PhysicsLog && !settings.DebugLog;
|
||||
|
||||
bool canFollow = _running && _tapes.DrivenVehicle is not null;
|
||||
_live.IsEnabled = canFollow;
|
||||
if (!canFollow)
|
||||
_live.IsChecked = false;
|
||||
ToolTip.SetTip(_live, App.Loc[canFollow ? "HaslerLiveTip" : "HaslerLiveIdle"]);
|
||||
}
|
||||
|
||||
private DockPanel Strip(string textKey, string buttonKey, Action<SimulatorSettings> enable)
|
||||
{
|
||||
var button = ToolButton(App.Loc[buttonKey]);
|
||||
button.Classes.Add("Accent");
|
||||
button.Margin = new Thickness(8, 0, 0, 0);
|
||||
button.Click += (_, _) => Enable(enable);
|
||||
DockPanel.SetDock(button, Dock.Right);
|
||||
return new DockPanel
|
||||
{
|
||||
Margin = new Thickness(8, 4),
|
||||
IsVisible = false,
|
||||
Children =
|
||||
{
|
||||
button,
|
||||
new TextBlock
|
||||
{
|
||||
Text = App.Loc[textKey],
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Only this one setting: other unsaved edits on the settings page are not this button's to save.</summary>
|
||||
private void Enable(Action<SimulatorSettings> enable)
|
||||
{
|
||||
var store = AppServices.Current.SettingsStore;
|
||||
enable(store.Settings);
|
||||
store.Save();
|
||||
LogEnabled?.Invoke();
|
||||
ShowWhatIsKnown();
|
||||
if (store.LastSaveFailed)
|
||||
_summary.Text = App.Loc["SettingsNotSaved"];
|
||||
}
|
||||
|
||||
private static Button ToolButton(string text) => new()
|
||||
{
|
||||
Content = text,
|
||||
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
|
||||
};
|
||||
|
||||
private static TextBlock Label(string text) => new() { Text = text, VerticalAlignment = VerticalAlignment.Center };
|
||||
|
||||
private RecorderKind Recorder => _recorder.SelectedIndex == 1 ? RecorderKind.Electronic : RecorderKind.Hasler;
|
||||
|
||||
private static TapeView NewView(RecorderKind kind) => kind == RecorderKind.Electronic
|
||||
? new ElectronicTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top }
|
||||
: new HaslerTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top };
|
||||
|
||||
/// <summary>
|
||||
/// The same trip drawn the other way. Hasler paper is fed by distance and
|
||||
/// nothing else; the screen of an electronic recorder runs on the clock, and
|
||||
/// may be switched to distance.
|
||||
/// </summary>
|
||||
private void RecorderChanged()
|
||||
{
|
||||
bool electronic = Recorder == RecorderKind.Electronic;
|
||||
_feed.IsEnabled = electronic;
|
||||
_feed.SelectedIndex = electronic ? 1 : 0;
|
||||
if ((_view is ElectronicTapeView) == electronic)
|
||||
return;
|
||||
|
||||
var old = _view;
|
||||
old.Hovered -= ShowCursor;
|
||||
_view = NewView(Recorder);
|
||||
_view.CopySettingsFrom(old);
|
||||
_view.Hovered += ShowCursor;
|
||||
_scroll.Content = _view;
|
||||
// The other view is not as tall; an offset kept from it would hide its top rows.
|
||||
_scroll.Offset = new Vector(_scroll.Offset.X, 0);
|
||||
_cursor.Text = "";
|
||||
}
|
||||
|
||||
private async Task RefreshListAsync(bool toDriven = false) =>
|
||||
ShowSessions(await Task.Run(_tapes.Sessions), toDriven);
|
||||
|
||||
/// <summary>The tape of the vehicle the player is in, in the session that runs.</summary>
|
||||
private static TapeFile? DrivenTape(IEnumerable<TapeSession> sessions) =>
|
||||
sessions.FirstOrDefault(session => session.Running)?.Tapes
|
||||
.FirstOrDefault(tape => tape.Role is TapeRole.Driven or TapeRole.StartedIn);
|
||||
|
||||
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}"))));
|
||||
|
||||
/// <summary>Sessions newest first, the newest one open until the reader has said otherwise.</summary>
|
||||
private void ShowSessions(IReadOnlyList<TapeSession> sessions, bool toDriven)
|
||||
{
|
||||
_running = sessions.Any(session => session.Running);
|
||||
_listed = Fingerprint(sessions);
|
||||
ShowWhatIsKnown();
|
||||
|
||||
string? driven = DrivenTape(sessions)?.Path;
|
||||
if (toDriven)
|
||||
_followed = driven;
|
||||
string? selected = toDriven && driven is not null ? driven : _shown?.Path;
|
||||
|
||||
foreach (var node in _list.Items.OfType<TreeViewItem>())
|
||||
_expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded;
|
||||
_list.Items.Clear();
|
||||
|
||||
(TreeViewItem Node, TreeViewItem Tape)? first = null, pick = null;
|
||||
for (int i = 0; i < sessions.Count; i++)
|
||||
{
|
||||
var node = SessionItem(sessions[i], _expanded.GetValueOrDefault(sessions[i].StartedUtc, i == 0));
|
||||
_list.Items.Add(node);
|
||||
foreach (var tape in node.Items.OfType<TreeViewItem>())
|
||||
{
|
||||
first ??= (node, tape);
|
||||
if (((TapeFile)tape.Tag!).Path == selected)
|
||||
pick = (node, tape);
|
||||
}
|
||||
}
|
||||
|
||||
_empty.Text = string.Format(App.Loc["HaslerNoTapes"], _tapes.Folder);
|
||||
_empty.IsVisible = first is null;
|
||||
_scroll.IsVisible = first is not null;
|
||||
if ((pick ?? first) is not { } chosen)
|
||||
{
|
||||
_shown = null;
|
||||
_view.Tape = null;
|
||||
_summary.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
chosen.Node.IsExpanded = true;
|
||||
_list.SelectedItem = chosen.Tape;
|
||||
}
|
||||
|
||||
private static 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 }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part)))
|
||||
: App.Loc["HaslerSession"];
|
||||
|
||||
var started = session.StartedUtc.ToLocalTime();
|
||||
var written = session.LastWrittenUtc.ToLocalTime();
|
||||
string when = $"{started:dd.MM HH:mm}";
|
||||
if (!session.Running && written - started >= TimeSpan.FromMinutes(1))
|
||||
when += $"–{written:HH:mm}";
|
||||
if (session.Tapes.Count > 0)
|
||||
when += " · " + string.Format(App.Loc["HaslerSessionTapes"], session.Tapes.Count);
|
||||
|
||||
var details = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
|
||||
details.Children.Add(new TextBlock { Text = when, FontSize = 11, Opacity = 0.7 });
|
||||
if (session.Running)
|
||||
details.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["HaslerSessionRunning"],
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = RunningBrush
|
||||
});
|
||||
|
||||
var name = new TextBlock { Text = title, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
ToolTip.SetTip(name, title);
|
||||
|
||||
var item = new TreeViewItem
|
||||
{
|
||||
Header = new StackPanel { Spacing = 1, Children = { name, details } },
|
||||
Tag = session,
|
||||
IsExpanded = open
|
||||
};
|
||||
foreach (var tape in session.Tapes)
|
||||
item.Items.Add(TapeItem(tape));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static TreeViewItem TapeItem(TapeFile file)
|
||||
{
|
||||
var text = new StackPanel { Spacing = 1 };
|
||||
text.Children.Add(new TextBlock
|
||||
{
|
||||
Text = file.Vehicle,
|
||||
FontWeight = file.Role == TapeRole.Other ? FontWeight.Normal : FontWeight.SemiBold,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
});
|
||||
string? role = file.Role switch
|
||||
{
|
||||
TapeRole.Driven => App.Loc["HaslerRoleDriven"],
|
||||
TapeRole.StartedIn => App.Loc["HaslerRoleStartedIn"],
|
||||
TapeRole.DrivenEarlier => App.Loc["HaslerRoleDrivenEarlier"],
|
||||
TapeRole.OwnConsist => App.Loc["HaslerRoleConsist"],
|
||||
_ => null
|
||||
};
|
||||
if (role is not null)
|
||||
text.Children.Add(new TextBlock { Text = role, FontSize = 11, Opacity = 0.7 });
|
||||
return new TreeViewItem { Header = text, Tag = file };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the starter noted at the launch the tape came from: the clock the
|
||||
/// scenery started at and the recorder its vehicle has. A tape from before
|
||||
/// the starter noted anything keeps the hour unknown and the recorder guessed.
|
||||
/// </summary>
|
||||
private void ApplyContext(TapeContext context)
|
||||
{
|
||||
_context = context;
|
||||
_startClock.Text = context.StartClock is { } clock
|
||||
? clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)
|
||||
: "";
|
||||
_recorder.SelectedIndex = context.Recorder == RecorderKind.Electronic ? 1 : 0;
|
||||
}
|
||||
|
||||
private async Task ShowAsync(TapeFile file, bool scrollToEnd)
|
||||
{
|
||||
if (_loading)
|
||||
return;
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
var tape = await Task.Run(() => _tapes.Load(file));
|
||||
_shown = file;
|
||||
if (tape is null)
|
||||
{
|
||||
_view.Tape = null;
|
||||
_summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path);
|
||||
return;
|
||||
}
|
||||
|
||||
_view.Tape = tape;
|
||||
_summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration),
|
||||
TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime),
|
||||
tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh,
|
||||
tape.AverageWithStopsKmh);
|
||||
if (_context?.Scenery is { } scenery)
|
||||
_summary.Text += " · " + string.Format(App.Loc["HaslerScenario"], scenery);
|
||||
if (_context?.Train is { } train)
|
||||
_summary.Text += " · " + string.Format(App.Loc["HaslerTrain"], train);
|
||||
// The log counts seconds from the start: an hour comes only from what the starter noted.
|
||||
if (string.IsNullOrWhiteSpace(_startClock.Text))
|
||||
_summary.Text += " · " + App.Loc["HaslerClockUnknown"];
|
||||
if (scrollToEnd)
|
||||
Dispatcher.UIThread.Post(() => _scroll.Offset = new Vector(double.MaxValue, _scroll.Offset.Y),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the list up with the sessions, and for a trip followed live re-reads the
|
||||
/// tape when the simulator has added to it. A change of vehicle or a new launch's
|
||||
/// own tape takes over; one picked by hand stays until then.
|
||||
/// </summary>
|
||||
private async Task LookAtSessionsAsync()
|
||||
{
|
||||
if (_loading || _looking)
|
||||
return;
|
||||
|
||||
_looking = true;
|
||||
try
|
||||
{
|
||||
var sessions = await Task.Run(_tapes.Sessions);
|
||||
bool live = _live.IsChecked == true;
|
||||
bool newDriven = live && DrivenTape(sessions) is { } driven && driven.Path != _followed;
|
||||
if (newDriven || Fingerprint(sessions) != _listed)
|
||||
{
|
||||
ShowSessions(sessions, toDriven: newDriven);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!live || _shown is not { } shown)
|
||||
return;
|
||||
var now = sessions.SelectMany(session => session.Tapes).FirstOrDefault(tape => tape.Path == shown.Path);
|
||||
if (now is null || (now.Size == shown.Size && now.LastWriteUtc == shown.LastWriteUtc))
|
||||
return;
|
||||
|
||||
// Following the end only when the reader is already there, so scrolling back is not undone.
|
||||
bool atEnd = _scroll.Offset.X + _scroll.Viewport.Width >= _scroll.Extent.Width - 4;
|
||||
await ShowAsync(now, scrollToEnd: atEnd);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_looking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FeedChanged()
|
||||
{
|
||||
bool byDistance = _feed.SelectedIndex != 1;
|
||||
if (_view.ByDistance == byDistance)
|
||||
return;
|
||||
_view.ByDistance = byDistance;
|
||||
_scale.Value = byDistance ? DefaultKmScale : DefaultMinuteScale;
|
||||
_scale.Maximum = byDistance ? 600 : 300;
|
||||
}
|
||||
|
||||
/// <summary>The whole tape, not just what is scrolled into view, drawn afresh at the chosen scale.</summary>
|
||||
private async Task ExportPngAsync()
|
||||
{
|
||||
if (_view.Tape is not { IsEmpty: false } tape || _shown is null)
|
||||
return;
|
||||
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = App.Loc["HaslerExportPng"],
|
||||
SuggestedFileName = _shown.Vehicle + ".png",
|
||||
DefaultExtension = "png",
|
||||
FileTypeChoices = new[] { new FilePickerFileType("PNG") { Patterns = new[] { "*.png" } } }
|
||||
});
|
||||
if (file is null)
|
||||
return;
|
||||
|
||||
var copy = NewView(Recorder);
|
||||
copy.CopySettingsFrom(_view);
|
||||
// The trip it was asked for: a reload may have come in behind the file picker.
|
||||
copy.Tape = tape;
|
||||
copy.Measure(Size.Infinity);
|
||||
if (copy.DesiredSize.Width > MaxExportWidth)
|
||||
{
|
||||
// A bitmap that wide is past what the renderer takes; the paper is squeezed to fit instead.
|
||||
copy.Scale *= MaxExportWidth / copy.DesiredSize.Width;
|
||||
copy.Measure(Size.Infinity);
|
||||
}
|
||||
copy.Arrange(new Rect(copy.DesiredSize));
|
||||
|
||||
try
|
||||
{
|
||||
using var bitmap = new RenderTargetBitmap(new PixelSize((int)Math.Ceiling(copy.DesiredSize.Width),
|
||||
(int)Math.Ceiling(copy.DesiredSize.Height)));
|
||||
bitmap.Render(copy);
|
||||
await using var stream = await file.OpenWriteAsync();
|
||||
bitmap.Save(stream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppServices.Current.Log.Log($"exporting {_shown.Path} to {file.Name}", ex);
|
||||
_summary.Text = string.Format(App.Loc["HaslerExportFailed"], file.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowCursor(TapeSample? sample)
|
||||
{
|
||||
if (sample is not { } s || _view.Tape is null)
|
||||
{
|
||||
_cursor.Text = "";
|
||||
return;
|
||||
}
|
||||
// Only what the record in view shows: a Hasler tape has no brake pipe and
|
||||
// marks current as on or off, and the electronic screen draws the current
|
||||
// and the vigilance alarm as channels, not values.
|
||||
var clock = TimeSpan.FromSeconds(_view.ClockAt(s) % 86400);
|
||||
string time = clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
_cursor.Text = Recorder == RecorderKind.Electronic
|
||||
? string.Format(App.Loc["ElectronicCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.PipePressBar,
|
||||
s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm))
|
||||
: string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar,
|
||||
YesNo(s.Current));
|
||||
}
|
||||
|
||||
private static string YesNo(bool on) => on ? App.Loc["HaslerYes"] : App.Loc["HaslerNo"];
|
||||
}
|
||||
202
StarterNG/Views/Tapes/TapeView.cs
Normal file
202
StarterNG/Views/Tapes/TapeView.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using StarterNG.Domain.Tapes;
|
||||
|
||||
namespace StarterNG.Views.Tapes;
|
||||
|
||||
/// <summary>
|
||||
/// What every way of drawing a recorded trip shares: the trip, how it is laid
|
||||
/// along the x axis - by distance or by time, at a scale - the clock it started
|
||||
/// at, and the pointer read-out.
|
||||
/// </summary>
|
||||
public abstract class TapeView : Control
|
||||
{
|
||||
public static readonly StyledProperty<HaslerTape?> TapeProperty =
|
||||
AvaloniaProperty.Register<TapeView, HaslerTape?>(nameof(Tape));
|
||||
|
||||
public static readonly StyledProperty<bool> ByDistanceProperty =
|
||||
AvaloniaProperty.Register<TapeView, bool>(nameof(ByDistance), true);
|
||||
|
||||
/// <summary>Pixels per kilometre when fed by distance, per minute when fed by time.</summary>
|
||||
public static readonly StyledProperty<double> ScaleProperty =
|
||||
AvaloniaProperty.Register<TapeView, double>(nameof(Scale), 40);
|
||||
|
||||
/// <summary>
|
||||
/// The time of day the recording started at. The log only counts seconds, so
|
||||
/// without it the clock counts from the start of the trip.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<TimeSpan> StartClockProperty =
|
||||
AvaloniaProperty.Register<TapeView, TimeSpan>(nameof(StartClock));
|
||||
|
||||
private static readonly IPen CursorPen = new Pen(new SolidColorBrush(Color.Parse("#FFB000")), 1);
|
||||
|
||||
static TapeView()
|
||||
{
|
||||
AffectsMeasure<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty);
|
||||
AffectsRender<TapeView>(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty);
|
||||
}
|
||||
|
||||
public HaslerTape? Tape
|
||||
{
|
||||
get => GetValue(TapeProperty);
|
||||
set => SetValue(TapeProperty, value);
|
||||
}
|
||||
|
||||
public bool ByDistance
|
||||
{
|
||||
get => GetValue(ByDistanceProperty);
|
||||
set => SetValue(ByDistanceProperty, value);
|
||||
}
|
||||
|
||||
public double Scale
|
||||
{
|
||||
get => GetValue(ScaleProperty);
|
||||
set => SetValue(ScaleProperty, value);
|
||||
}
|
||||
|
||||
public TimeSpan StartClock
|
||||
{
|
||||
get => GetValue(StartClockProperty);
|
||||
set => SetValue(StartClockProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>The sample under the pointer, or null once it leaves the tape.</summary>
|
||||
public event Action<TapeSample?>? Hovered;
|
||||
|
||||
protected abstract double LeftMargin { get; }
|
||||
|
||||
private const double RightMargin = 24;
|
||||
|
||||
protected abstract double TapeHeight { get; }
|
||||
|
||||
protected double? CursorX { get; private set; }
|
||||
|
||||
protected double TopSpeed => TapeFigures.TopSpeed(Tape?.MaxSpeedKmh ?? 0);
|
||||
|
||||
protected double Length => Tape is null ? 0 : ByDistance ? Tape.DistanceM / 1000.0 : Tape.Duration / 60.0;
|
||||
|
||||
protected double TapeRight => LeftMargin + Length * Scale;
|
||||
|
||||
protected double XOf(TapeSample s) =>
|
||||
LeftMargin + (ByDistance ? s.DistanceM / 1000.0 : (s.Time - (Tape?.StartTime ?? 0)) / 60.0) * Scale;
|
||||
|
||||
private double PositionOf(double x) =>
|
||||
ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0);
|
||||
|
||||
/// <summary>Seconds since midnight at the sample, by the clock the trip started at.</summary>
|
||||
public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0);
|
||||
|
||||
/// <summary>What the view shows, for another one to show the same.</summary>
|
||||
public void CopySettingsFrom(TapeView other)
|
||||
{
|
||||
Tape = other.Tape;
|
||||
ByDistance = other.ByDistance;
|
||||
Scale = other.Scale;
|
||||
StartClock = other.StartClock;
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) =>
|
||||
new(TapeRight + RightMargin, TapeHeight);
|
||||
|
||||
public sealed override void Render(DrawingContext context)
|
||||
{
|
||||
DrawBlank(context);
|
||||
|
||||
var tape = Tape;
|
||||
if (tape is null || tape.IsEmpty)
|
||||
return;
|
||||
|
||||
DrawTape(context, tape);
|
||||
DrawCursor(context);
|
||||
}
|
||||
|
||||
/// <summary>What is there with no trip on it.</summary>
|
||||
protected abstract void DrawBlank(DrawingContext context);
|
||||
|
||||
/// <summary>A trip with something in it, over the blank.</summary>
|
||||
protected abstract void DrawTape(DrawingContext context, HaslerTape tape);
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
var tape = Tape;
|
||||
if (tape is null || tape.IsEmpty)
|
||||
return;
|
||||
|
||||
double x = Math.Clamp(e.GetPosition(this).X, LeftMargin, TapeRight);
|
||||
int index = tape.IndexAt(PositionOf(x), ByDistance);
|
||||
CursorX = x;
|
||||
InvalidateVisual();
|
||||
Hovered?.Invoke(index < 0 ? null : tape.Samples[index]);
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
CursorX = null;
|
||||
InvalidateVisual();
|
||||
Hovered?.Invoke(null);
|
||||
}
|
||||
|
||||
private void DrawCursor(DrawingContext context)
|
||||
{
|
||||
if (CursorX is { } x)
|
||||
context.DrawLine(CursorPen, new Point(x, 0), new Point(x, TapeHeight));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A record as one line, one column of pixels at a time: per column the
|
||||
/// lowest and highest point in it, so a long trip draws as fast as a short
|
||||
/// one and what happens during a stop on a distance-fed tape still shows as
|
||||
/// one stroke.
|
||||
/// </summary>
|
||||
protected StreamGeometry Trace(HaslerTape tape, Func<TapeSample, double> y)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using var g = geometry.Open();
|
||||
bool begun = false;
|
||||
int column = int.MinValue;
|
||||
double top = 0, bottom = 0, first = 0, last = 0;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (column == int.MinValue)
|
||||
return;
|
||||
// Enter where the column started, leave where it ended.
|
||||
var (a, b) = first <= last ? (top, bottom) : (bottom, top);
|
||||
foreach (double v in new[] { first, a, b, last })
|
||||
{
|
||||
var p = new Point(column, v);
|
||||
if (!begun)
|
||||
{
|
||||
g.BeginFigure(p, false);
|
||||
begun = true;
|
||||
}
|
||||
else
|
||||
g.LineTo(p);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var s in tape.Samples)
|
||||
{
|
||||
int c = (int)Math.Round(XOf(s));
|
||||
double value = y(s);
|
||||
if (c != column)
|
||||
{
|
||||
Flush();
|
||||
column = c;
|
||||
top = bottom = first = value;
|
||||
}
|
||||
top = Math.Min(top, value);
|
||||
bottom = Math.Max(bottom, value);
|
||||
last = value;
|
||||
}
|
||||
Flush();
|
||||
if (begun)
|
||||
g.EndFigure(false);
|
||||
return geometry;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user