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:
maj00r
2026-11-01 16:06:16 +01:00
parent 82429010d4
commit 6e45f5de59
12 changed files with 2707 additions and 0 deletions

View 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;
}
}
}

View 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/&lt;vehicle&gt;.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);
}

View 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);
}
}

View 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;
}

View 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;
}
}

View 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));
}

View 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;
}
}

View 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"];
}

View 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;
}
}