From e9e0d9605fd2d72d8825c416b9cd93f81d73a614 Mon Sep 17 00:00:00 2001 From: Sander van Vliet Date: Wed, 12 Jul 2023 20:37:54 +0200 Subject: [PATCH] Fix an issue where the KOMs would not be rendered correctly on the elevation profile --- .../Controls/CalculatedElevationProfile.cs | 15 +- .../ElevationProfileLayeredRenderOperation.cs | 114 ++++++--- src/RoadCaptain/Segment.cs | 5 + .../WhenCalculatingClimbMarkerPositions.cs | 238 ++++++++++++++++++ 4 files changed, 334 insertions(+), 38 deletions(-) create mode 100644 test/RoadCaptain.App.Shared.Tests.Unit/Controls/WhenCalculatingClimbMarkerPositions.cs diff --git a/src/RoadCaptain.App.Shared/Controls/CalculatedElevationProfile.cs b/src/RoadCaptain.App.Shared/Controls/CalculatedElevationProfile.cs index f1ff6d41..13d6c5cb 100644 --- a/src/RoadCaptain.App.Shared/Controls/CalculatedElevationProfile.cs +++ b/src/RoadCaptain.App.Shared/Controls/CalculatedElevationProfile.cs @@ -20,8 +20,8 @@ internal class CalculatedElevationProfile public ImmutableList ElevationLines { get; } public ImmutableArray Points { get; } - private CalculatedElevationProfile( - IEnumerable elevationGroups, + private CalculatedElevationProfile(IEnumerable elevationGroups, + List trackPoints, double minAltitude, double maxAltitude, double totalDistance) @@ -33,7 +33,7 @@ internal class CalculatedElevationProfile AltitudeDelta = minAltitude < 0 ? -minAltitude + maxAltitude : maxAltitude; ElevationGroups = elevationGroups.ToImmutableList(); ElevationLines = CalculateElevationLines(); - Points = ElevationGroups.SelectMany(eg => eg.Points).ToImmutableArray(); + Points = trackPoints.ToImmutableArray();// ElevationGroups.SelectMany(eg => eg.Points).ToImmutableArray(); } private ImmutableList CalculateElevationLines() @@ -79,6 +79,7 @@ private ImmutableList CalculateElevationLines() private static CalculatedElevationProfile Empty => new( new List(), + new List(), 0, 0, 0 @@ -162,6 +163,7 @@ internal static CalculatedElevationProfile From(PlannedRoute? route, List(); ElevationGroup? currentGroup = null; + var overallIndex = 0; foreach (var point in routePoints) { @@ -176,7 +178,7 @@ internal static CalculatedElevationProfile From(PlannedRoute? route, List? _climbMarkersOnRoute; + private List<(Segment Climb, TrackPoint Start, TrackPoint Finish)> _climbMarkersOnRoute; private readonly SKPaint _finishLinePaint; private readonly SKPaint _circlePaint; private readonly SKPaint _finishCirclePaint; @@ -79,10 +79,84 @@ public ElevationProfileLayeredRenderOperation() set { _route = value; - _elevationProfile = CalculatedElevationProfile.From(_route, Segments); - _renderParameters = RenderParameters.From(RenderMode, Bounds, _elevationProfile, RiderPosition, Markers); - _elevationProfile.CalculatePathsForElevationGroups(_renderParameters); + + if (_route != null && Markers != null && Segments != null) + { + _elevationProfile = CalculatedElevationProfile.From(_route, Segments); + _renderParameters = + RenderParameters.From(RenderMode, Bounds, _elevationProfile, RiderPosition, Markers); + _elevationProfile.CalculatePathsForElevationGroups(_renderParameters); + + _climbMarkersOnRoute = CalculateClimbMarkers(_route, Markers.Where(m => m.Type == SegmentType.Climb).ToList(), Segments); + } + else + { + // Reset everything + _elevationProfile = null; + _renderParameters = null; + _climbMarkersOnRoute = new List<(Segment Climb, TrackPoint Start, TrackPoint Finish)>(); + } + } + } + + public static List<(Segment Climb, TrackPoint Start, TrackPoint Finish)> CalculateClimbMarkers(PlannedRoute plannedRoute, List markers, List segments) + { + var elevationProfile = CalculatedElevationProfile.From(plannedRoute, segments); + + var result = new List<(Segment Climb, TrackPoint Start, TrackPoint Finish)>(); + Segment? currentClimb = null; + TrackPoint? start = null; + + var index = 0; + while (index < elevationProfile.Points.Length) + { + var point = elevationProfile.Points[index]; + + if (currentClimb == null) + { + var climb = markers.SingleOrDefault(m => m.A.IsCloseTo(point)); + + if (climb != null) + { + currentClimb = climb; + start = point; + } + else + { + index++; + continue; + } + } + + while (index < elevationProfile.Points.Length) + { + var nextPoint = elevationProfile.Points[index]; + // Check if this point is still on the climb + if (currentClimb.Contains(nextPoint)) + { + index++; + continue; + } + + // Check if the last point was close to the end of the segment + if (currentClimb.B.IsCloseTo(elevationProfile.Points[index - 1])) + { + // Yup, add this climb + result.Add(( + currentClimb, + start, + finish: elevationProfile.Points[index - 1] + )); + } + + currentClimb = null; + start = null; + + break; + } } + + return result; } public Rect Bounds @@ -231,7 +305,7 @@ private void RenderElevationLines(SKCanvas canvas, RenderParameters renderParame List? markers, CalculatedElevationProfile elevationProfile) { - if (markers == null || !markers.Any()) + if (_climbMarkersOnRoute == null) { return; } @@ -242,37 +316,13 @@ private void RenderElevationLines(SKCanvas canvas, RenderParameters renderParame // Because we flipped, we also need to translate canvas.Translate(renderParameters.TranslateX, -renderParameters.PlotHeight); - - var climbMarkersOnRoute = _climbMarkersOnRoute; - if (climbMarkersOnRoute == null) + foreach (var climbMarker in _climbMarkersOnRoute) { - var climbMarkers = markers.Where(m => m.Type == SegmentType.Climb).ToList(); - - _climbMarkersOnRoute = climbMarkersOnRoute = elevationProfile - .Points - .Select(point => new - { - Point = point, - Marker = climbMarkers.FirstOrDefault(m => m.Contains(point)) - }) - .Where(x => x.Marker != null) - .GroupBy(x => x.Marker!.Id, x => x.Marker!, (_, values) => values.First()) - .ToList(); + DrawStartMarker(canvas, climbMarker.Start, renderParameters); + DrawFinishFlag(canvas, climbMarker.Finish, renderParameters); } - foreach (var climbMarker in climbMarkersOnRoute) - { - var closestA = elevationProfile.GetClosestPointOnRoute(climbMarker.A); - var closestB = elevationProfile.GetClosestPointOnRoute(climbMarker.B); - - if (closestA != null && closestB != null && closestA.DistanceOnSegment < closestB.DistanceOnSegment) - { - DrawStartMarker(canvas, closestA, renderParameters); - DrawFinishFlag(canvas, closestB, renderParameters); - } - } - // Back to normal canvas.Restore(); } diff --git a/src/RoadCaptain/Segment.cs b/src/RoadCaptain/Segment.cs index 698b851c..c870d87a 100644 --- a/src/RoadCaptain/Segment.cs +++ b/src/RoadCaptain/Segment.cs @@ -377,5 +377,10 @@ public bool Contains(TrackPoint position, out TrackPoint? match) return false; } + + public override string ToString() + { + return $"segment {Id}"; + } } } diff --git a/test/RoadCaptain.App.Shared.Tests.Unit/Controls/WhenCalculatingClimbMarkerPositions.cs b/test/RoadCaptain.App.Shared.Tests.Unit/Controls/WhenCalculatingClimbMarkerPositions.cs new file mode 100644 index 00000000..ceb84fe4 --- /dev/null +++ b/test/RoadCaptain.App.Shared.Tests.Unit/Controls/WhenCalculatingClimbMarkerPositions.cs @@ -0,0 +1,238 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using FluentAssertions; +using RoadCaptain.App.Shared.Controls; +using Xunit; + +namespace RoadCaptain.App.Shared.Tests.Unit.Controls +{ + public class WhenCalculatingClimbMarkerPositions + { + private List _markers = null!; + private List _segments = null!; + + public WhenCalculatingClimbMarkerPositions() + { + BuildSegmentsAndMarkers(); + } + + private static PlannedRoute CreatePlannedRoute(params string[] segmentIds) + { + var plannedRoute = new PlannedRoute + { + World = new World { Id = "watopia", ZwiftId = ZwiftWorldId.Watopia, Name = "Watopia" }, + WorldId = "watopia", + Sport = SportType.Cycling, + ZwiftRouteName = "Test Zwift Route" + }; + + foreach (var segmentId in segmentIds) + { + plannedRoute.RouteSegmentSequence.Add(new SegmentSequence(segmentId, SegmentDirection.AtoB, + SegmentSequenceType.Regular)); + } + + return plannedRoute; + } + + [Fact] + public void GivenRouteWithClimbSegmentInReverse_NoClimbMarkersAreGenerated() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3"), + _markers.Where(m => m.Id == "climb-1-rev").ToList(), + _segments); + + climbMarkers.Should().BeEmpty(); + } + + [Fact] + public void GivenRouteWithClimbSegmentForward_SingleClimbMarkerIsGenerated() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3"), + _markers.Where(m => m.Id == "climb-1").ToList(), + _segments); + + climbMarkers.Should().NotBeEmpty(); + climbMarkers[0].Climb.Id.Should().Be("climb-1"); + } + + [Fact] + public void GivenRouteCrossingTheSameClimbSegmentForwardTwice_TwoClimbMarkersAreGenerated() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3", "segment-1", "segment-2", "segment-3"), + _markers, + _segments); + + climbMarkers.Should().HaveCount(2); + climbMarkers[0].Climb.Id.Should().Be("climb-1"); + climbMarkers[1].Climb.Id.Should().Be("climb-1"); + } + + [Fact] + public void GivenRouteCrossingTheSameClimbSegmentForwardTwice_TwoClimbMarkersAreGeneratedWithCorrectStartFinishIndexes() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3", "segment-1", "segment-2", "segment-3"), + _markers, + _segments); + + climbMarkers.Should().HaveCount(2); + climbMarkers[0].Start.Index.Should().NotBe(climbMarkers[1].Start.Index); + climbMarkers[0].Finish.Index.Should().NotBe(climbMarkers[1].Finish.Index); + } + + [Fact] + public void GivenRouteWithForwardAndReverseClimbSegment_TwoClimbMarkersAreGenerated() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3", "segment-2-rev", "segment-1-rev"), + _markers, + _segments); + + climbMarkers.Should().HaveCount(2); + climbMarkers[0].Climb.Id.Should().Be("climb-1"); + climbMarkers[1].Climb.Id.Should().Be("climb-1-rev"); + } + + [Fact] + public void GivenRouteWithForwardReverseForwardClimbSegments_ThreeClimbMarkersAreGenerated() + { + var climbMarkers = CalculateClimbMarkers( + CreatePlannedRoute("segment-1", "segment-2", "segment-3", "segment-2-rev", "segment-1-rev", "segment-1", "segment-2", "segment-3"), + _markers, + _segments); + + climbMarkers.Should().HaveCount(3); + climbMarkers[0].Climb.Id.Should().Be("climb-1"); + climbMarkers[1].Climb.Id.Should().Be("climb-1-rev"); + climbMarkers[2].Climb.Id.Should().Be("climb-1"); + } + + private List<(Segment Climb, TrackPoint Start, TrackPoint Finish)> CalculateClimbMarkers(PlannedRoute plannedRoute, List markers, List segments) + { + return ElevationProfileLayeredRenderOperation.CalculateClimbMarkers(plannedRoute, markers, segments); + } + + private void BuildSegmentsAndMarkers() + { + var segment1Point1 = new TrackPoint(0, 0, 0, ZwiftWorldId.Watopia); + var segment1Point2 = segment1Point1.ProjectTo(90, 100, 20); + var segment1Point3 = segment1Point2.ProjectTo(90, 100, 20); + + var segment2Point1 = segment1Point3.ProjectTo(90, 100, 90); + var segment2Point2 = segment2Point1.ProjectTo(90, 100, 100); + var segment2Point3 = segment2Point2.ProjectTo(90, 100, 90); + + var segment3Point1 = segment2Point3.ProjectTo(90, 100, 75); + var segment3Point2 = segment3Point1.ProjectTo(90, 100, 70); + var segment3Point3 = segment3Point2.ProjectTo(90, 100, 50); + + _segments = new List + { + new(new List + { + segment1Point1, + segment1Point2, + segment1Point3 + }) + { + Id = "segment-1", + Name = "Segment 1" + }, + new(new List + { + segment2Point1, + segment2Point2, + segment2Point3 + }) + { + Id = "segment-2", + Name = "Segment 2" + }, + new(new List + { + segment3Point1, + segment3Point2, + segment3Point3 + }) + { + Id = "segment-3", + Name = "Segment 3", + }, + }; + + foreach (var segment in _segments) + { + segment.Type = SegmentType.Segment; + segment.Sport = SportType.Cycling; + segment.CalculateDistances(); + } + + var reversedSegments = _segments + .Select(ReverseSegment) + .ToList(); + + _segments.AddRange(reversedSegments); + + _markers = new List + { + new(new List + { + Clone(segment1Point2), + Clone(segment1Point3), + Clone(segment2Point1), + Clone(segment2Point2) + }) + { + Id = "climb-1", + Name = "Climb 1", + Type = SegmentType.Climb, + Sport = SportType.Cycling + }, + new(new List + { + Clone(segment2Point2), + Clone(segment2Point1), + Clone(segment1Point3), + Clone(segment1Point2), + }) + { + Id = "climb-1-rev", + Name = "Climb 1 reverse" + } + }; + + foreach (var marker in _markers) + { + marker.Type = SegmentType.Climb; + marker.Sport = SportType.Cycling; + marker.CalculateDistances(); + } + } + + private static Segment ReverseSegment(Segment input) + { + var reverseSegment = new Segment( + input.Points.AsEnumerable().Reverse().Select(Clone).ToList()) + { + Id = input.Id + "-rev", + Name = input.Name + " reverse", + Type = input.Type, + Sport = input.Sport + }; + + reverseSegment.CalculateDistances(); + + return reverseSegment; + } + + private static TrackPoint Clone(TrackPoint input) + { + return new TrackPoint(input.Latitude, input.Longitude, input.Altitude, input.WorldId); + } + } +}