using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; namespace Eu07.BlackBox.Views; /// /// The map of the scenery at a moment of the recording: the rail tracks with the switches /// as they lay, the signals in the colour of what they showed, every vehicle where it was, /// and the way the user's train went, coloured by how its speed stood against the AI's. /// North is up, as on the map in the simulator. Dragged to move, the wheel zooms, a /// double click shows it all. /// public sealed class MapView : Control { private const double MinScale = 0.002; private const double MaxScale = 40; private const double FollowScale = 1.2; private Recording? _recording; private int _builtRevision = -1; private int _builtTracks = -1; private StreamGeometry? _tracks; private readonly List<(int Index, int Branch, StreamGeometry Way)> _switchWays = new(); private readonly StreamGeometry?[] _trail = new StreamGeometry?[5]; private Rect _world; private double _centerX, _centerZ; private double _scale = 0.2; // pixels per metre private bool _fitted; private Point? _dragFrom; private (double X, double Z) _dragCenter; private Point? _pointer; private double _time; private bool _follow; /// The user moved the map away from the train: following is off. public event Action? FollowStopped; public MapView() { ClipToBounds = true; Focusable = true; } public Recording? Recording { get => _recording; set { _recording = value; _builtRevision = -1; _builtTracks = -1; _fitted = false; InvalidateVisual(); } } public double Time { get => _time; set { _time = value; InvalidateVisual(); } } /// Keeps the user's train in the middle. public bool Follow { get => _follow; set { // Close enough to see the train go, unless already closer. if (value && !_follow) _scale = Math.Max(_scale, FollowScale); _follow = value; InvalidateVisual(); } } public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings(); /// Closer by the factor given, about the middle. public void ZoomBy(double factor) { _scale = Math.Clamp(_scale * factor, MinScale, MaxScale); InvalidateVisual(); } /// The map as a whole in view. public void Fit() { FitWorld(); InvalidateVisual(); } // Also from the render, which must not ask for another one. private void FitWorld() { if (_world.Width <= 0 && _world.Height <= 0) return; var size = Bounds.Size; if (size.Width <= 0 || size.Height <= 0) return; _centerX = -_world.Center.X; _centerZ = -_world.Center.Y; _scale = Math.Clamp(Math.Min(size.Width / Math.Max(_world.Width, 50), size.Height / Math.Max(_world.Height, 50)) * 0.92, MinScale, MaxScale); if (_follow) _scale = Math.Max(_scale, FollowScale); _fitted = true; } // The map's plane as the simulator's map draws it, seen from above: x to the left (the game's x runs the // other way than a map's east), z up the screen. private static Point Plane(double x, double z) => new(-x, -z); private Point ToScreen(double x, double z) => new((_centerX - x) * _scale + Bounds.Width / 2, (_centerZ - z) * _scale + Bounds.Height / 2); private (double X, double Z) ToWorld(Point screen) => (_centerX - (screen.X - Bounds.Width / 2) / _scale, _centerZ - (screen.Y - Bounds.Height / 2) / _scale); private void Build(Recording recording) { if (recording.Tracks.Count != _builtTracks) { _builtTracks = recording.Tracks.Count; _switchWays.Clear(); var tracks = new StreamGeometry(); double left = double.MaxValue, top = double.MaxValue, right = double.MinValue, bottom = double.MinValue; using (var g = tracks.Open()) { foreach (var track in recording.Tracks) { if (track.Points.Count < 2) continue; foreach (var p in track.Points) { left = Math.Min(left, -p.X); right = Math.Max(right, -p.X); top = Math.Min(top, -p.Z); bottom = Math.Max(bottom, -p.Z); } if (track.Kind == TrackKind.SwitchBranch) { var way = new StreamGeometry(); using (var w = way.Open()) Polyline(w, track.Points); _switchWays.Add((track.Index, track.Branch, way)); } else Polyline(g, track.Points); } } _tracks = tracks; _world = left <= right ? new Rect(new Point(left, top), new Point(right, bottom)) : default; } BuildTrail(recording); _builtRevision = recording.Revision; } private static void Polyline(StreamGeometryContext g, IReadOnlyList points) { g.BeginFigure(Plane(points[0].X, points[0].Z), false); for (int i = 1; i < points.Count; i++) g.LineTo(Plane(points[i].X, points[i].Z)); g.EndFigure(false); } /// The way the user's train went, a geometry for each judgement of its speed. private void BuildTrail(Recording recording) { var lines = new List>[_trail.Length]; for (int i = 0; i < lines.Length; i++) lines[i] = new List>(); var cab = recording.Cab; int open = -1; for (int i = 1; i < cab.Count; i++) { var before = cab[i - 1]; var sample = cab[i]; if (sample.Vehicle != before.Vehicle || sample.Time - before.Time > 1.0) { open = -1; continue; } if (before.Position.DistanceTo(sample.Position) < 0.5 && open >= 0) continue; int kind = (int)DrivingSummary.Judge(sample); if (kind != open) { lines[kind].Add(new List { Plane(before.Position.X, before.Position.Z) }); open = kind; } lines[kind][^1].Add(Plane(sample.Position.X, sample.Position.Z)); } for (int kind = 0; kind < _trail.Length; kind++) { if (lines[kind].Count == 0) { _trail[kind] = null; continue; } var geometry = new StreamGeometry(); using (var g = geometry.Open()) { foreach (var line in lines[kind].Where(l => l.Count > 1)) { g.BeginFigure(line[0], false); foreach (var p in line.Skip(1)) g.LineTo(p); g.EndFigure(false); } } _trail[kind] = geometry; } } public override void Render(DrawingContext context) { var size = Bounds.Size; context.FillRectangle(Palette.MapBackground, new Rect(size)); var recording = _recording; if (recording is null) return; if (recording.Revision != _builtRevision) Build(recording); if (!_fitted) FitWorld(); if (_follow && recording.CabPositionAt(_time) is { } followed) { _centerX = followed.X; _centerZ = followed.Z; } var toScreen = Matrix.CreateTranslation(_centerX, _centerZ) * Matrix.CreateScale(_scale, _scale) * Matrix.CreateTranslation(size.Width / 2, size.Height / 2); double pixel = 1 / _scale; using (context.PushTransform(toScreen)) { // The tracks on a dark casing, so they stand out where they cross and run close. var casing = new Pen(Palette.TrackCasing, 5 * pixel, lineCap: PenLineCap.Round); if (_tracks is not null) context.DrawGeometry(null, casing, _tracks); foreach (var (_, _, way) in _switchWays) context.DrawGeometry(null, casing, way); if (_tracks is not null) context.DrawGeometry(null, new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round), _tracks); var off = new Pen(Palette.SwitchOff, 1.7 * pixel, lineCap: PenLineCap.Round); var on = new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round); // The ways not taken under those taken. foreach (var (index, branch, way) in _switchWays) { int state = recording.SwitchAt(index, _time); if (state >= 0 && state != branch) context.DrawGeometry(null, off, way); } foreach (var (index, branch, way) in _switchWays) { int state = recording.SwitchAt(index, _time); if (state < 0 || state == branch) context.DrawGeometry(null, on, way); } // The way the train went, in the colour of the judgement. for (int kind = 0; kind < _trail.Length; kind++) if (_trail[kind] is { } trail) context.DrawGeometry(null, new Pen(Palette.Of((SpeedJudgement)kind), 3.5 * pixel, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), trail); } DrawStopPoints(context, recording); DrawHornPoints(context, recording); DrawSignals(context, recording); DrawPassings(context, recording); DrawVehicles(context, recording); DrawUserTrain(context, recording); DrawCamera(context, recording); DrawScale(context, size); DrawLegend(context, size); DrawHover(context, recording); } private bool Visible(Point p, double margin = 20) => p.X >= -margin && p.Y >= -margin && p.X <= Bounds.Width + margin && p.Y <= Bounds.Height + margin; private void DrawSignals(DrawingContext context, Recording recording) { double radius = _scale > 0.5 ? 4.5 : _scale > 0.1 ? 3.5 : 2.5; var outline = new Pen(Palette.Background, 1); foreach (var signal in recording.Signals) { var p = ToScreen(signal.Position.X, signal.Position.Z); if (!Visible(p)) continue; context.DrawEllipse(Palette.Of(recording.AspectAt(signal.Index, _time)), outline, p, radius, radius); } } /// /// The stop points (W4): a white plate, blue where the user's train stopped, red where it /// missed the stop; the name of the station once close enough to read it. /// // The way the trains a stop point is for go: a wedge ahead of the plate, as a train sees the W4 coming. private static void DrawStopWay(DrawingContext context, Point plate, float heading, double size, IBrush fill, IPen outline) { // the map shows x to the left, z up: a heading from +z towards +x turns up the screen and to the left var ahead = new Vector(-Math.Sin(heading), -Math.Cos(heading)); var across = new Vector(-ahead.Y, ahead.X); var tip = plate + ahead * (size * 4.2); var root = plate + ahead * (size * 1.8); var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(tip, true); g.LineTo(root + across * (size * 1.3)); g.LineTo(root - across * (size * 1.3)); g.EndFigure(true); } context.DrawGeometry(fill, outline, geometry); } private void DrawStopPoints(DrawingContext context, Recording recording) { if (recording.StopPoints.Count == 0) return; var outcomes = new Dictionary<(double, double), StationKind>(); foreach (var station in recording.UserStations()) { if (station.Time > _time || station.Kind is not (StationKind.Stopped or StationKind.Missed)) continue; var key = (Math.Round(station.Position.X, 1), Math.Round(station.Position.Z, 1)); if (!outcomes.TryGetValue(key, out var was) || was != StationKind.Missed) outcomes[key] = station.Kind; } // Close up every plate is named; from afar each station once, where the names don't cover each other. bool near = _scale > 0.35; double size = _scale > 0.5 ? 4 : 3; var outline = new Pen(Palette.MapBackground, 1); var labels = new List<(Point At, string Name, IBrush Fill)>(); foreach (var point in recording.StopPoints) { var p = ToScreen(point.Position.X, point.Position.Z); if (!Visible(p)) continue; var fill = outcomes.TryGetValue((Math.Round(point.Position.X, 1), Math.Round(point.Position.Z, 1)), out var kind) ? kind == StationKind.Missed ? Palette.Bad : Palette.Station : Palette.StopPoint; var plate = new Rect(p.X - size, p.Y - size * 1.3, size * 2, size * 2.6); // a W32 hollow, in the colour a W4 would be filled with if (point.ForShortTrains) context.DrawRectangle(Palette.MapBackground, new Pen(fill, 1.5), plate, 1.5, 1.5); else context.DrawRectangle(fill, outline, plate, 1.5, 1.5); if (point.HasHeading) DrawStopWay(context, p, point.Heading, size, fill, outline); labels.Add((p, StationNames.Display(point.Station), fill)); } var delays = StationProgress.StationDelays(recording, _time); var named = new HashSet(StringComparer.OrdinalIgnoreCase); var taken = new List(); // The label of the user's train stays clear. if (recording.CabPositionAt(_time) is { } train) { var mark = ToScreen(train.X, train.Z); taken.Add(new Rect(mark.X - 10, mark.Y - 24, 170, 34)); } var culture = System.Globalization.CultureInfo.CurrentCulture; foreach (var (p, name, fill) in labels) { if (!near && !named.Add(name)) continue; var text = Text(name, Palette.Text, near ? 11 : 10); FormattedText? late = delays.TryGetValue(name, out var minutes) ? Text(string.Format(culture, Strings["Minutes"], minutes), Palette.Delay(minutes), near ? 11 : 10) : null; double width = text.Width + (late is null ? 0 : late.Width + 6) + 8; var box = new Rect(p.X + size + 3, p.Y - text.Height / 2 - 1, width, text.Height + 2); if (taken.Exists(r => r.Inflate(2).Intersects(box))) continue; taken.Add(box); context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.8), null, box); context.DrawText(text, new Point(box.X + 4, box.Y + 1)); if (late is not null) context.DrawText(late, new Point(box.X + 4 + text.Width + 6, box.Y + 1)); } } /// The horn points (W6): a diamond, green where the user sounded the horn, red where not. private void DrawHornPoints(DrawingContext context, Recording recording) { if (recording.HornPoints.Count == 0) return; var checks = recording.UserHornChecks().Where(c => c.Request.Time <= _time).ToList(); double size = _scale > 0.5 ? 5 : 3.5; var outline = new Pen(Palette.MapBackground, 1); foreach (var point in recording.HornPoints) { var p = ToScreen(point.Position.X, point.Position.Z); if (!Visible(p)) continue; var check = checks.LastOrDefault(c => c.Request.Position.DistanceTo(point.Position) < 1); var fill = check is null ? Palette.HornPoint : check.Sounded ? Palette.Good : Palette.Bad; context.DrawGeometry(fill, outline, Diamond(p, size)); } } private static StreamGeometry Diamond(Point p, double size) { var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(p + new Vector(0, -size), true); g.LineTo(p + new Vector(size, 0)); g.LineTo(p + new Vector(0, size)); g.LineTo(p + new Vector(-size, 0)); g.EndFigure(true); } return geometry; } /// Where the user looked from, when not from the cab: a dot with a wedge of where it looked. private void DrawCamera(DrawingContext context, Recording recording) { if (recording.CameraAt(_time) is not { } camera || camera.Mode == CameraMode.Cab) return; var p = ToScreen(camera.Position.X, camera.Position.Z); if (!Visible(p, 60)) return; // The heading from +z towards +x: up the screen and to the left. const double reach = 46, half = 0.45; var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(p, true); g.LineTo(p + new Vector(-Math.Sin(camera.Heading - half), -Math.Cos(camera.Heading - half)) * reach); g.ArcTo(p + new Vector(-Math.Sin(camera.Heading + half), -Math.Cos(camera.Heading + half)) * reach, new Size(reach, reach), 0, false, SweepDirection.CounterClockwise); g.EndFigure(true); } context.DrawGeometry(Palette.Faded(Palette.HornPoint, 0.18), new Pen(Palette.Faded(Palette.HornPoint, 0.5), 1), geometry); context.DrawEllipse(Palette.HornPoint, new Pen(Palette.MapBackground, 1.5), p, 4.5, 4.5); } /// Where the user's train passed signals; those at danger ringed red. private void DrawPassings(DrawingContext context, Recording recording) { var passed = new Pen(Palette.Within, 1.5); var danger = new Pen(Palette.Over, 2.5); foreach (var passing in recording.UserPassings()) { if (passing.Time > _time) continue; var p = ToScreen(passing.Position.X, passing.Position.Z); if (!Visible(p)) continue; if (passing.AtDanger) context.DrawEllipse(null, danger, p, 10, 10); else context.DrawEllipse(null, passed, p, 7, 7); } } private void DrawVehicles(DrawingContext context, Recording recording) { int user = recording.CabAt(_time) is { } sample && _time - sample.Time < 1.5 ? sample.Vehicle : 0; foreach (var state in recording.VehiclesAt(_time)) { var info = recording.Vehicle(state.Id); var brush = state.Id == user ? Palette.UserTrain : state.Driving == Driver.Ai ? Palette.VehicleAi : Palette.Vehicle; DrawVehicle(context, state.Position.X, state.Position.Z, state.Heading, info?.Length ?? 15, info?.Width ?? 3, brush); } } private void DrawVehicle(DrawingContext context, double x, double z, double heading, double length, double width, IBrush brush) { var center = ToScreen(x, z); if (!Visible(center, length * _scale + 10)) return; // The front from +z towards +x: up the screen and to the left. double fx = Math.Sin(heading), fz = Math.Cos(heading); double half = Math.Max(length * _scale, 5) / 2, side = Math.Max(width * _scale, 3) / 2; var front = new Vector(-fx, -fz); var left = new Vector(-fz, fx); var geometry = new StreamGeometry(); using (var g = geometry.Open()) { g.BeginFigure(center + front * half + left * side, true); g.LineTo(center + front * half - left * side); g.LineTo(center - front * half - left * side); g.LineTo(center - front * half + left * side); g.EndFigure(true); } context.DrawGeometry(brush, new Pen(Palette.Background, 0.8), geometry); } /// The head of the user's train between its samples, ten a second, on top of everything. private void DrawUserTrain(DrawingContext context, Recording recording) { if (recording.CabPositionAt(_time) is not { } position) return; var p = ToScreen(position.X, position.Z); context.DrawEllipse(Palette.MapBackground, new Pen(Palette.UserTrain, 2), p, 7, 7); context.DrawEllipse(Palette.UserTrain, null, p, 3, 3); // The train and how late it is, by its mark. if (StationProgress.DelayAt(recording, _time) is not { } delay) return; var culture = System.Globalization.CultureInfo.CurrentCulture; var train = Text(delay.Train, Palette.UserTrain, 11, FontWeight.SemiBold); var late = Text(Math.Abs(delay.Minutes) < 0.5 ? Strings["OnTime"] : string.Format(culture, Strings["Minutes"], delay.Minutes), Palette.Delay(delay.Minutes), 11, FontWeight.SemiBold); var box = TrainLabel(p, train, late); context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.85), null, box); context.DrawText(train, new Point(box.X + 4, box.Y + 2)); context.DrawText(late, new Point(box.X + train.Width + 12, box.Y + 2)); } /// Where the label of the user's train goes, by its mark. private static Rect TrainLabel(Point mark, FormattedText train, FormattedText late) => new(mark.X + 12, mark.Y - 22, train.Width + late.Width + 16, train.Height + 4); private void DrawScale(DrawingContext context, Size size) { double metres = Nice(120 / _scale); double length = metres * _scale; var y = size.Height - 14; var pen = new Pen(Palette.DimText, 1.5); context.DrawLine(pen, new Point(size.Width - 16 - length, y), new Point(size.Width - 16, y)); context.DrawLine(pen, new Point(size.Width - 16 - length, y - 4), new Point(size.Width - 16 - length, y + 1)); context.DrawLine(pen, new Point(size.Width - 16, y - 4), new Point(size.Width - 16, y + 1)); var text = Text(metres >= 1000 ? $"{metres / 1000:0.#} km" : $"{metres:0} m", Palette.DimText, 11); context.DrawText(text, new Point(size.Width - 16 - length / 2 - text.Width / 2, y - 18)); } private void DrawLegend(DrawingContext context, Size size) { var rows = new (IBrush Brush, string Text)[] { (Palette.Within, Strings["LegendWithin"]), (Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)), (Palette.Over, Strings["LegendOver"]), (Palette.Autopilot, Strings["LegendAutopilot"]), (Palette.NoData, Strings["LegendNoData"]) }; var marks = new List<(IBrush Brush, string Text, int Shape)>(); if (_recording is { } r) { if (r.StopPoints.Any(point => !point.ForShortTrains)) marks.Add((Palette.StopPoint, Strings["LegendStopPoint"], 0)); if (r.StopPoints.Any(point => point.ForShortTrains)) marks.Add((Palette.StopPoint, Strings["LegendShortStop"], 3)); if (r.HornPoints.Count > 0) marks.Add((Palette.HornPoint, Strings["LegendHorn"], 1)); if (r.Cameras.Count > 0) marks.Add((Palette.HornPoint, Strings["LegendCamera"], 2)); } var texts = rows.Select(row => Text(row.Text, Palette.DimText, 11)).Concat(marks.Select(m => Text(m.Text, Palette.DimText, 11))).ToList(); const double line = 17; double width = texts.Max(t => t.Width) + 44, height = texts.Count * line + 14 + (marks.Count > 0 ? 6 : 0); var box = new Rect(10, size.Height - height - 10, width, height); context.DrawRectangle(Palette.Faded(Palette.Card, 0.9), new Pen(Palette.CardBorder, 1), box); double y = box.Top + 7; for (int i = 0; i < rows.Length; i++, y += line) { context.DrawLine(new Pen(rows[i].Brush, 4, lineCap: PenLineCap.Round), new Point(box.Left + 12, y + 8), new Point(box.Left + 26, y + 8)); context.DrawText(texts[i], new Point(box.Left + 34, y)); } if (marks.Count > 0) { context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y + 3), new Point(box.Right - 10, y + 3)); y += 6; } for (int i = 0; i < marks.Count; i++, y += line) { var c = new Point(box.Left + 19, y + 8); if (marks[i].Shape == 0) context.DrawRectangle(marks[i].Brush, null, new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5); else if (marks[i].Shape == 3) context.DrawRectangle(null, new Pen(marks[i].Brush, 1.5), new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5); else if (marks[i].Shape == 1) context.DrawGeometry(marks[i].Brush, null, Diamond(c, 4.5)); else context.DrawEllipse(marks[i].Brush, null, c, 4, 4); context.DrawText(texts[rows.Length + i], new Point(box.Left + 34, y)); } } /// The name of the signal or the vehicle under the pointer. private void DrawHover(DrawingContext context, Recording recording) { if (_pointer is not { } pointer || _dragFrom is not null) return; string? label = null; double best = 10; foreach (var signal in recording.Signals) { double d = Distance(ToScreen(signal.Position.X, signal.Position.Z), pointer); if (d < best) { best = d; var aspect = recording.AspectAt(signal.Index, _time); label = aspect is { } a ? $"{signal.Name} · {AspectText(Strings, a.Speed1, a.Speed2)}" : signal.Name; } } var culture = CultureInfo.CurrentCulture; foreach (var point in recording.StopPoints) { double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer); if (d < best) { best = d; label = string.Format(culture, Strings[point.ForShortTrains ? "ShortStopHover" : "StopPointHover"], StationNames.Display(point.Station)); if (point.Platform > 0) label += " · " + string.Format(culture, Strings["PlatformHover"], point.PlatformLength); else if (point.Platform < 0) label += " · " + string.Format(culture, Strings["ShortTrainsHover"], point.PlatformLength); // the preferred place to stop, as the driver reads it: the head, or the middle of the train if (point.StopAt < 0) label += " · " + string.Format(culture, Strings["StopHeadHover"], -point.StopAt); else if (point.StopAt > 0) label += " · " + string.Format(culture, Strings["StopMiddleHover"], point.StopAt); } } foreach (var point in recording.HornPoints) { double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer); if (d < best) { best = d; label = string.Format(culture, Strings["HornHover"], point.Name, point.Duration); } } foreach (var state in recording.VehiclesAt(_time)) { double d = Distance(ToScreen(state.Position.X, state.Position.Z), pointer); if (d < best && recording.Vehicle(state.Id) is { } info) { best = d; label = string.IsNullOrEmpty(info.Train) ? $"{info.Name} · {Math.Abs(state.SpeedKmh):0} km/h" : $"{info.Name} ({info.Train}) · {Math.Abs(state.SpeedKmh):0} km/h"; } } if (label is null) return; var text = Text(label, Palette.Text, 12); var box = new Rect(pointer.X + 14, pointer.Y - 12 - text.Height, text.Width + 16, text.Height + 8); context.DrawRectangle(Palette.Faded(Palette.Card, 0.95), new Pen(Palette.CardBorder, 1), box, 6, 6); context.DrawText(text, new Point(box.Left + 8, box.Top + 4)); } /// An aspect as the speeds it gives: stop, a speed, or go. public static string AspectText(IBlackBoxStrings strings, float speed1, float speed2) { if (speed1 == 0) return strings["AspectStop"]; if (speed1 < 0 && speed2 < 0) return strings["AspectFree"]; string first = speed1 < 0 ? "∞" : speed1.ToString("0", CultureInfo.InvariantCulture); string second = speed2 < 0 ? "∞" : speed2.ToString("0", CultureInfo.InvariantCulture); return $"{first}/{second}"; } private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y)); private static double Nice(double value) { double power = Math.Pow(10, Math.Floor(Math.Log10(value))); double unit = value / power; return (unit < 2 ? 1 : unit < 5 ? 2 : 5) * power; } internal static FormattedText Text(string text, IBrush brush, double size, FontWeight weight = FontWeight.Normal) => new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, weight == FontWeight.Normal ? Typeface.Default : new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, weight), size, brush); protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); var point = e.GetCurrentPoint(this); if (!point.Properties.IsLeftButtonPressed) return; if (e.ClickCount == 2) { if (_follow) StopFollowing(); Fit(); return; } _dragFrom = point.Position; _dragCenter = (_centerX, _centerZ); e.Pointer.Capture(this); } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); var position = e.GetPosition(this); _pointer = position; if (_dragFrom is { } from) { var moved = position - from; if (_follow && (Math.Abs(moved.X) > 3 || Math.Abs(moved.Y) > 3)) StopFollowing(); if (!_follow) { _centerX = _dragCenter.X + moved.X / _scale; _centerZ = _dragCenter.Z + moved.Y / _scale; } } InvalidateVisual(); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); _dragFrom = null; e.Pointer.Capture(null); } protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); _pointer = null; InvalidateVisual(); } protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); var at = e.GetPosition(this); var (x, z) = ToWorld(at); _scale = Math.Clamp(_scale * Math.Pow(1.25, e.Delta.Y), MinScale, MaxScale); if (!_follow) { // The place under the pointer stays under it. _centerX = x + (at.X - Bounds.Width / 2) / _scale; _centerZ = z + (at.Y - Bounds.Height / 2) / _scale; } InvalidateVisual(); e.Handled = true; } private void StopFollowing() { _follow = false; FollowStopped?.Invoke(); } }