The Run Away! app: Drawing gradient lines on top of Maps with SkiaSharp

The most wanted piece

Since the Xamarin.Forms: it works post, this feature was the most wanted piece.

Site

Hi Roubachof,

Hope all is well. I can across your sharpnado site and was impressed with what you were doing with xamrin and SkiaSharp. I'm trying to implement something similar with my trail app and Google Maps. I have a hiking trail app and would like to use SkiaSharp to display heaps of info without killing the responsiveness of Google Maps.

Site

When I saw the screens designed by Souffl as I started working on the Exalt Training App, I immediately noticed them. Those evil gradients drawn on Google Maps...

For a start, even the native android google maps sdk misses the gradient polyline feature (but the iOS sdk supports it, weirdly):
https://issuetracker.google.com/issues/35828754

But I knew, deep down in my heart, it existed a secret path to gradients...

A bit of history

In 2007 I started to work at RTE Technologies (hey guys :) as a Junior Software Architect under the supervision of Frédéric Lucazeau (truely a great guy and master of C#) on a new Geolocation project, a vehicle fleet tracker:
1. You mounted a gps device in a car, which communicated by gprs to a TCP server,
2. Positions and info were saved in a SQL SERVER 2005 database,
3. Glorious asmx SOAP web services exposed api, .Net Remoting was used for real-time,
5. Even more glorious WinForms rich client displayed them on a... Virtual-Earth map (js map hosted in a web view).

We wanted to display, routes, pins of custom images, sometimes dozens of vehicles routes info at the same time...

We tried to use the map api, it didn't end well... It was awfully laggy.

So why not draw on top of glorious IE6 web component ?
After many web researches, I dug up an mysterious feature of IE6 called Binary Behavior, which allowed such a thing (thank you Scobie Smith for your help ;). Now we could draw with GDI+ on top of the map, and have a reactive client.

But one issue remained: how could we convert LatLong coordinates to pixels ?

rte_geoloc

We'll fast forward to a few years later

With SkiaSharp over Google Maps, the issue is exactly the same than with Virtual Earth back in the days.

You have to know that every map control is using the same kind of projection to display the earth as a 2D map: the Mercator projection. So they all use the same formula to convert a LatLong coordinate to your screen pixels. I found the formula back in 2007, I don't remember where, but now it's everywhere on the internet.

So I just took the exact same code I was using back in the days and add a pixelDensity parameter:

internal static class MapToolBox  
{
    /// <summary>
    /// The radius of the earth (in meters) - should never change!
    /// </summary>
    private const double EarthRadius = 6378137d;

    /// <summary>
    /// calculated circumference of the earth
    /// </summary>
    private const double EarthCircumference = EarthRadius * 2d * Math.PI;

    private const double EarthHalfCircumference = EarthCircumference / 2d;
    private const int TileSize = 256;

    public static Point LatLongToXyAtZoom(LatLong latLong, double zoom, double pixelDensity)
    {
        Debug.Assert(zoom >= 0, "Expecting positive zoom factor");

        int pixelsPerTile = (int)(TileSize * pixelDensity);

        // double arc = VirtualEarthToolBox.earthCircumference / ((1 << zoom) * pixelsPerTile);
        var arc = EarthCircumference / (Math.Pow(2, zoom) * pixelsPerTile);
        var sinLat = Math.Sin(latLong.Latitude * Math.PI / 180d);
        var metersY = EarthRadius / 2 * Math.Log((1 + sinLat) / (1 - sinLat));
        var metersX = EarthRadius * latLong.Longitude * Math.PI / 180d;

        return new Point(
            (int)((EarthHalfCircumference + metersX) / arc),
            (int)((EarthHalfCircumference - metersY) / arc));
    }
}

And voila, that's really the only difficulty about drawing on google maps.

Now that we have resolved this central issue, let's have some fun!

SkiaSharpnado

In order to showcase the mighty power of SkiaSharp, I created a Github repo:

https://github.com/roubachof/SkiaSharpnado

For now, you will find two netstandard projects ready to be reused:

  1. SkiaSharpnado
  2. SkiaSharpnado.Maps

In the near future, I will release some SkiaSharp components through a SkiaSharpnado nuget package.

The SkiaSharpnado.Maps project contains all the bits to draw the gradient paths on Maps.
It may be also released as a nuget package: your choice :)

Those projects are used by the Sample project which is a Xamarin.Forms app targeting, iOS, Android and UWP.
Yes, I didn't forget UWP this time, even if the result is not as good as the others platforms, but stay tuned for the disappointment :)

Sample solution

Run Away! A VIP app (1)

Of course, what better app than a training app for showing gradients?

But what is a training app without data ?

So instead of picking random numbers I decided to use a TCX file parser, I found this one: https://github.com/MelHarbour/TcxTools, and created a netstandard nuget package for convenience.

So the activities you will see are extracted from Xamarin.Forms masters running apps:

Thanks again to those great dedicated souls!

You can even export you own activities as TCX files, and add them in the Resources folder, it will pick them automatically.

The ActivityHeaderPage

  • I used text semantic and followed the elevation color guidelines of the material design
  • I used Font Awesome for the icons (thanks Xappy for the tutorial ;)
  • I used Google Material Dark theme to design the app
iOS Android UWP

The only special thing about this screen is the little colored bar you can see below the activity title. These are not random colors. There are in fact the effort dispersion based on the heart rates of each activity.
The effort colors are defined in the Colors.xaml file:

  • We can see that Steven's activity is the most balanced, it's quite an adventure, 7 hours of cycling. Steven climbed the Joux-Plane Pass, which is a famous stage of the "Tour de France". There were some calm moments, flat segments, going down the pass, and some really challenging ones: obviously going up the pass (9% average, 11km).

  • David entry is really heart intensive with more than 50% of the time close to max heart rate. He went running on a very hot day at the beginning of the afternoon on a steep road (thank you again for your sacrifice :).

  • Finally, Glenn pushed hard on the pedals on a rather flat area to achieve nearly 30 km/h on average. The effort was constant, quite strong (most of it qualifies as anaerobic span), with little variations.

Now let's dive into code!

SKColorDispersionBarView

So this bar is made with... SkiaSharp obviously, and the code is pretty simple.

public class SKColorDispersionBarView : SKCanvasView  
{
    public static readonly BindableProperty DispersionProperty =
        BindableProperty.Create(
            nameof(Dispersion),
            typeof(List<IDispersionSpan>),
            typeof(SKColorDispersionBarView),
            defaultValue: null,
            propertyChanged: DispersionPropertyChanged);

    public List<IDispersionSpan> Dispersion
    {
        get => (List<IDispersionSpan>)GetValue(DispersionProperty);
        set => SetValue(DispersionProperty, value);
    }
    private static void DispersionPropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
    {
        var barView = (SKColorDispersionBarView)bindable;
        barView.InvalidateSurface();
    }

    protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
    {
        base.OnPaintSurface(e);

        SKSurface surface = e.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        if (Dispersion == null || Dispersion.Count == 0)
        {
            return;
        }

        float width = CanvasSize.Width;
        float height = CanvasSize.Height;
        double totalCount = Dispersion.Sum(d => d.Value);
        float currentX = 0;

        width -= SkiaHelper.ToPixel(Dispersion.Count - 1);

        using (var paint = new SKPaint { Style = SKPaintStyle.Fill })
        {
            foreach (var dispersionSpan in Dispersion)
            {
                double rectangleWidth = width * dispersionSpan.Value / totalCount;

                SKColor effortStartColor = dispersionSpan.Color.ToSKColor();
                SKColor effortTargetColor = effortStartColor.Darken();

                var upperLeft = new SKPoint(currentX, 0);
                var bottomRight = new SKPoint(currentX + (float)rectangleWidth, height);

                using (var shader = SKShader.CreateLinearGradient(
                    upperLeft,
                    bottomRight,
                    new[] { effortStartColor, effortTargetColor },
                    null,
                    SKShaderTileMode.Clamp))
                {
                    paint.Shader = shader;

                    canvas.DrawRect(new SKRect(upperLeft.X, upperLeft.Y, bottomRight.X, bottomRight.Y), paint);
                }

                currentX += (float)rectangleWidth + 1;
            }
        }
    }
}

The dispersion is computed in the ActivityHeaderPageViewModel. What I call dispersion, is a list of DispersionSpan. In our app case, the DispersionSpan color is the effort color (given by the heart rate), and the value is the total time (in ms) spent in this effort interval.

public struct DispersionSpan : IDispersionSpan  
{
    public DispersionSpan(Color color, double value)
    {
        Color = color;
        Value = value;
    }

    public Color Color { get; }

    public double Value { get; private set; }

    public void IncrementValue(double value)
    {
        Value += value;
    }
}

I use an EffortComputer to declare the effort span, and get the color matching a given heartrate:

public static class HumanEffortComputer  
{
    public static EffortComputer ByHeartBeat { get; }

    static HumanEffortComputer()
    {
        ByHeartBeat = new EffortComputer(
            new List<EffortSpan>
                {
                    new EffortSpan(0f, GetResourceColor("ColorEffortUnknown"), AppResources.PaceUnknown),
                    new EffortSpan(0.3f, GetResourceColor("ColorEffortLightest"), AppResources.PaceVeryLight),
                    new EffortSpan(0.6f, GetResourceColor("ColorEffortLight"), AppResources.PaceLight),
                    new EffortSpan(0.7f, GetResourceColor("ColorEffortAerobic"), AppResources.PaceAerobic),
                    new EffortSpan(0.8f, GetResourceColor("ColorEffortAnaerobic"), AppResources.PaceAnaerobic),
                    new EffortSpan(0.9f, GetResourceColor("ColorEffortMax"), AppResources.PaceMax),
                },
            maxValue: 180);

        ...
    }

    private static Color GetResourceColor(string key)
    {
        if (Application.Current.Resources.TryGetValue(key, out var value))
        {
            return (Color)value;
        }

        throw new InvalidOperationException($"key {key} not found in the resource dictionary");
    }
}

...

public class EffortComputer  
{
    private readonly List<EffortSpan> _effortSpans;

    private double _defaultMaxEffortValue;

    public EffortComputer(List<EffortSpan> effortSpans, double defaultMaxEffortValue);

    public EffortSpan LastSpan { get; }

    public EffortComputer OverrideDefaultMaxValue(double defaultMaxValue);

    public EffortSpan GetSpan(double? effortValue);

    public Color GetColor(double? effortValue, double? maxEffortValue = null);
}

You can see the EffortSpan as the running domain version of the dispersion span.
The EffortComputer is responsible:

  1. For the declaration of your domain effort spans
  2. For getting a span from the source value of your effort span (in our case the heart rate)

For example, if the heart rate is 130 and the max effort value is 180, this will be converted to 130/180 = 0.72, and will match the "Aerobic" effort span.

So here what is happening when I load the ActivityHeaderPageViewModel:

  1. All activities are retrieved from the TcxActivityService
  2. Each activity is converted to an ActivityHeader
  3. All points of an activity are processed and grouped by effort according to the threshold specified in each EffortSpan
  4. The list of dispersions is bound to the Dispersion property of the SKColorDispersionBarView which invalidates the canvas

The ActivityPage

And now ladies and gentlemen, the long awaited moment, let's draw some gradient lines on Maps!

Functionnally speaking, the colors displayed on the map are computed the same way than the SKColorDispersionBarView: by the HumanEffortComputer. The only thing that changed is that we interpolate the color to give that nice gradient touch. The start and end points are marked by icons colorized by the athlete heartrate at this precise moment.

Effort <-> Color cheat sheet
  • Steven's map is more colorful as we see earlier. Calories weren't exported for some reason, I'm thinking int overflow. Lots of heartrate variations, we can see clearly the top of the Pass, the effort goes from max (red) to light (blue). I suspect a quick break for a Pastis

  • David's path is painted in max effort with an impressive average rate of 159 bpm (I think I would have died at minute 3)

  • Glenn track is yellow-orangish with a constant effort on a disappointing UWP implementation

Before diving into our SessionMap view, let's first have a look to the GetColor method of our effort computer:

public Color GetColor(double? effortValue, double? maxEffortValue = null)  
{
    double currentPercentage = (effortValue ?? 0) / (maxEffortValue ?? _defaultMaxEffortValue);

    if (currentPercentage >= LastSpan.Threshold)
    {
        return LastSpan.Color;
    }

    var sourceSpan = _effortSpans[0];
    var targetSpan = _effortSpans[1];

    EffortSpan previousSpan = _effortSpans[0];
    foreach (var currentSpan in _effortSpans)
    {
        sourceSpan = previousSpan;
        targetSpan = currentSpan;

        if (currentPercentage < currentSpan.Threshold)
        {
            break;
        }

        previousSpan = currentSpan;
    }

    double percentToTarget =
        (currentPercentage - sourceSpan.Threshold) / (targetSpan.Threshold - sourceSpan.Threshold);

    var sourceColor = sourceSpan.Color;
    var targetColor = targetSpan.Color;

    // Define color
    return Color.FromRgba(
        sourceColor.R + (percentToTarget * (targetColor.R - sourceColor.R)),
        sourceColor.G + (percentToTarget * (targetColor.G - sourceColor.G)),
        sourceColor.B + (percentToTarget * (targetColor.B - sourceColor.B)),
        sourceColor.A + (percentToTarget * (targetColor.A - sourceColor.A)));
}

Let's take a practical example:

The heartrate point to render as color is equal to 130 and the max effort is 180.
We first get the matching color span:

  • 130/180 = 0.72
  • this match the new EffortSpan(0.7f, GetResourceColor("ColorEffortAerobic"), AppResources.PaceAerobic)
  • which gives the yellowish color

We then interpolate the color between the matching color span, and the next one according to this value.
Since 0.72 is 20% of the value (current threshold is 0.7) till the next span threshold (0.8), we add 20% of the next color (orangish) to our yellowish color.

This is how each point of our gradient lines will be built.

The dreadful Joux-Plane Pass and its Pastis stop

The SessionMap view (SkiaSharpnado.Maps.Views)

This is our main piece.
It's made of the GoogleMaps component from the legendary amay077, and a simple SkiaSharp overlay.

<?xml version="1.0" encoding="UTF-8"?>  
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"  
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:forms="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             xmlns:googleMaps="clr-namespace:Xamarin.Forms.GoogleMaps;assembly=Xamarin.Forms.GoogleMaps"
             x:Class="SkiaSharpnado.Maps.Presentation.Views.SessionMap.SessionMap">
    <ContentView.Content>
        <Grid RowSpacing="0">
            <googleMaps:Map Grid.Row="0"
                            x:Name="GoogleMap"
                            MapType="Satellite" />

            <forms:SKCanvasView Grid.Row="0"
                                x:Name="MapOverlay"
                                InputTransparent="True"
                                PaintSurface="MapOnPaintSurface" />
        </Grid>
    </ContentView.Content>
</ContentView>  

It has a PathThickness bindable property if you want some big thick fluffy gradient (here PathThickness="6"):

Its input data is a SessionMapInfo, which is basically a list of Gps points with session infos, the ISessionDisplayablePoint.

public static readonly BindableProperty SessionMapInfoProperty = BindableProperty.Create(  
typeof(SessionMapInfo),  
    nameof(SessionMapInfo),
    typeof(SessionMap),
    propertyChanged: SessionMapInfoChanged);
public class SessionMapInfo  
{
    public IReadOnlyList<SessionDisplayablePoint> SessionPoints { get; }

    public Bounds Region { get; }

    public int TotalDurationInSeconds { get; }
}

public interface ISessionDisplayablePoint  
{
    TimeSpan Time { get; }

    Color MapPointColor { get; }

    int? Altitude { get; }

    int? HeartRate { get; }

    double? Speed { get; }

    LatLong Position { get; }

    bool HasMarker { get; }

    string Label { get; }

    int? Distance { get; }
}

In the case of our Run Away! app, the SessionMapInfo is built in the ActivityPageViewModel as follows:

  1. The selected tcx activity is retrieved from the ITcxActivityService
  2. The Tcx TrackPoint list is converted to domain ActivityPoint list
  3. We specify some parameters like the number of markers and the distance label interval
  4. Then SessionMap.Create factory is called and will compute speed, color from the EffortComputer, distance, etc...

Initialization

When the map receives the SessionMapInfo, it starts by initializing the start and end SVG icons and our layers.

I use static layers to cache the labels and the markers. For now they have little interest, but we'll speak about them and their UpdateMaxTime method in a future post...

After that I struggle with different Maps platform implementation to update the camera to the session region. You will find some hacky Device.RuntimePlatform == whatever.

The UWP support is really poor in amay077 component, leading to a truely disappointing experience on this platform...

When the camera is ready, I finally invalidate the canvas and start to draw!

Drawing gradient lines

OK. So, you remember my old Virtual-Earth coordinates converter? I use it in what I call a PositionConverter.

public class PositionConverter  
{
    private double _pixelDensity;
    private double _zoomLevel;
    private Point _topLeftPoint;

    public PositionConverter()
    {
    }

    public Size MapSize { get; private set; }

    public Point this[LatLong location]
        => MapToolBox.LatLongToXyAtZoom(location, _zoomLevel, _pixelDensity) 
            - new Size(_topLeftPoint.X, _topLeftPoint.Y);

    public void UpdateCamera(
        Xamarin.Forms.GoogleMaps.Map mapRendering, 
        Size mapSize, 
        double pixelDensity)
    {
        UpdateCamera(
           mapRendering.CameraPosition.Target.ToLatLong(), 
           mapRendering.CameraPosition.Zoom, 
           mapSize, 
           pixelDensity);
    }

    public void UpdateCamera(
        LatLong centerLocation, 
        double zoomLevel, 
        Size mapSize, 
        double pixelDensity)
    {
        _zoomLevel = zoomLevel;
        MapSize = mapSize;
        _pixelDensity = pixelDensity;

        _topLeftPoint = 
            MapToolBox.LatLongToXyAtZoom(centerLocation, zoomLevel, _pixelDensity) 
                - new Size(mapSize.Width / 2, mapSize.Height / 2);
    }
}

And this is the time we mix all together:

  1. The effort color computed by the EffortComputer
  2. The PositionConverter that converts GPS coordinates to pixels
  3. The start and end icons, and the layers of markers and labels

First I update my position converter to the current GoogleMap zoom and position (we'll see why we need to test the _movingCameraPosition field later):

if (_movingCameraPosition != null)  
{
    _positionConverter.UpdateCamera(
        _movingCameraPosition.Target.ToLatLong(),
        _movingCameraPosition.Zoom,
        new Size(info.Width, info.Height),
        SkiaHelper.PixelPerUnit);
}
else  
{
    _positionConverter.UpdateCamera(
        GoogleMap,
        new Size(info.Width, info.Height),
        SkiaHelper.PixelPerUnit);
}

Next:

  1. We initialize the resources
  2. We draw gradient lines between all the points
  3. We update the marker layer
  4. We update the distance label layer
  5. We draw the marker layer
  6. We draw the start and end icons
  7. We draw the distance label layer
  8. We dispose the resources
// Initialize resources

for (int index = 0; index < sessionPoints.Count; index++)  
{
    ISessionDisplayablePoint sessionPoint = sessionPoints[index];

    // This is for later :)
    if (sessionPoint.Time > MaxTime)
    {
        break;
    }

    SKPoint pathPoint = sessionPoint.Position != LatLong.Empty
        ? _positionConverter[sessionPoint.Position].ToSKPoint()
        : SKPoint.Empty;

    SKColor pointColor = sessionPoint.MapPointColor.ToSKColor();

    if (previousPoint != SKPoint.Empty && pathPoint != SKPoint.Empty)
    {
        using (var shader = SKShader.CreateLinearGradient(
            previousPoint,
            pathPoint,
            new[] { previousColor, pointColor },
            null,
            SKShaderTileMode.Clamp))
        {
            _gradientPathPaint.Shader = shader;

            canvas.DrawLine(
                previousPoint.X, 
                previousPoint.Y, 
                pathPoint.X, 
                pathPoint.Y, 
                _gradientPathPaint);

            _gradientPathPaint.Shader = null;
        }
    }

    // Update or fill marker layer

    // Update or fill text layer
}

// Draw marker layer

// Draw text layer

// Draw start and

// Dispose resources

Pretty simple uh?

The issue of inter-lines

So you can see that I don't draw a SKPath, but lines. I didn't find a way to have a shader for each segment of a path (please yell if such a thing exists).
Issue with contiguous lines is when you begin to have a thick line, you will start to see gap.

Here we have several options:

1. Don't care

The smartphone screen is small so our eyes can't really see: it's a bit fuzzy but nothing more. People tend to underestimate this solution :).

2. Apply a round cap to our lines with SKPaint.StrokeCap = SKStrokeCap.Round

A bit too artistic if you want my opinion.

3. Apply a blur to our lines, so it seems more connected SKPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Solid, 2)

This is best trade-off so far.

4. Reduce the sampling rate

For this path, we have 7000 points for a really small area inducing visual fragmentation. 500 points should be enough. This would also improve the rendering time and user experience. Surely the best solution but requires some brain.

Moving the map

It's time to speak about the _movingCameraPosition field...
The GoogleMaps component provide some useful events. The one which is crucial in this first implementation is CameraMoving.
We will subscribe to this event, and invalidate the surface when the event is raised.
Because when the map is moved around, we sure need to redraw our overlay to compute the new pixel coordinates of our training session.

private void CameraMoving(object sender, CameraMovingEventArgs e)  
{
    _movingCameraPosition = e.Position;
    MapOverlay.InvalidateSurface();
}

Here, the user experience will depend on the complexity of the SkiaSharp canvas.

For example, for glenn's session, on Android or iOS, it is quite smooth. It is so because the sampling rate is not too high: we render 1253 points.
The rendering is around 28 ms, but there are sometimes some GC pauses which double the rendering time (00:00:00.0593218).

[0:] END OF => MapOnPaintSurface (00:00:00.0290150)
[0:] END OF => MapOnPaintSurface (00:00:00.0309950)
[0:] END OF => MapOnPaintSurface (00:00:00.0291784)
[0:] END OF => MapOnPaintSurface (00:00:00.0278641)
07-15 22:48:45.659 I/harpnado.sampl(27977): Explicit concurrent copying GC freed 20896(713KB) AllocSpace objects, 2(32KB) LOS objects, 49% free, 5MB/11MB, paused 377us total 13.548ms  
07-15 22:48:45.660 D/Mono    (27977): GC_TAR_BRIDGE bridges 186 objects 186 opaque 0 colors 186 colors-bridged 186 colors-visible 186 xref 0 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.03ms tarjan 0.03ms scc-setup 0.03ms gather-xref 0.01ms xref-setup 0.01ms cleanup 0.02ms  
07-15 22:48:45.660 D/Mono    (27977): GC_BRIDGE: Complete, was running for 15.15ms  
07-15 22:48:45.660 D/Mono    (27977): GC_MINOR: (Nursery full) time 12.55ms, stw 13.48ms promoted 219K major size: 10304K in use: 9448K los size: 1024K in use: 678K  
[0:] END OF => MapOnPaintSurface (00:00:00.0593218)
[0:] END OF => MapOnPaintSurface (00:00:00.0274473)
[0:] END OF => MapOnPaintSurface (00:00:00.0268013)
[0:] END OF => MapOnPaintSurface (00:00:00.0293194)
[0:] END OF => MapOnPaintSurface (00:00:00.0299854)
[0:] END OF => MapOnPaintSurface (00:00:00.0287836)
[0:] END OF => MapOnPaintSurface (00:00:00.0255712)
[0:] END OF => MapOnPaintSurface (00:00:00.0296736)

When we zoom on the map, the rendering we'll be faster cause even if the points are computed, they are less object rendered on the screen.

[0:] END OF => MapOnPaintSurface (00:00:00.0196192)
[0:] END OF => MapOnPaintSurface (00:00:00.0209986)
[0:] END OF => MapOnPaintSurface (00:00:00.0229831)
[0:] END OF => MapOnPaintSurface (00:00:00.0197108)
[0:] END OF => MapOnPaintSurface (00:00:00.0205919)
[0:] END OF => MapOnPaintSurface (00:00:00.0214638)
[0:] END OF => MapOnPaintSurface (00:00:00.0213657)
[0:] END OF => MapOnPaintSurface (00:00:00.0203782)
07-15 22:57:10.210 I/harpnado.sampl(27977): Explicit concurrent copying GC freed 33248(1355KB) AllocSpace objects, 95(1776KB) LOS objects, 25% free, 17MB/23MB, paused 317us total 21.734ms  
07-15 22:57:10.211 D/Mono    (27977): GC_TAR_BRIDGE bridges 72 objects 72 opaque 0 colors 72 colors-bridged 72 colors-visible 72 xref 0 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.27ms tarjan 0.03ms scc-setup 0.02ms gather-xref 0.01ms xref-setup 0.01ms cleanup 0.14ms  
07-15 22:57:10.211 D/Mono    (27977): GC_BRIDGE: Complete, was running for 23.26ms  
07-15 22:57:10.211 D/Mono    (27977): GC_MINOR: (Nursery full) time 4.94ms, stw 5.90ms promoted 209K major size: 16224K in use: 14142K los size: 2048K in use: 531K  
[0:] END OF => MapOnPaintSurface (00:00:00.0567726)
[0:] END OF => MapOnPaintSurface (00:00:00.0205811)
[0:] END OF => MapOnPaintSurface (00:00:00.0243889)
[0:] END OF => MapOnPaintSurface (00:00:00.0239623)

But for Steven's one, it is a tad less pleasant but still usable though: 7137 points.

[0:] END OF => MapOnPaintSurface (00:00:00.1683637)
[0:] END OF => MapOnPaintSurface (00:00:00.1336551)
07-15 23:00:54.046 I/harpnado.sampl(27977): Explicit concurrent copying GC freed 4923(191KB) AllocSpace objects, 0(0B) LOS objects, 24% free, 18MB/24MB, paused 316us total 22.986ms  
07-15 23:00:54.047 D/Mono    (27977): GC_TAR_BRIDGE bridges 44 objects 44 opaque 0 colors 44 colors-bridged 44 colors-visible 44 xref 0 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.09ms tarjan 0.01ms scc-setup 0.02ms gather-xref 0.01ms xref-setup 0.01ms cleanup 0.05ms  
07-15 23:00:54.047 D/Mono    (27977): GC_BRIDGE: Complete, was running for 24.01ms  
07-15 23:00:54.047 D/Mono    (27977): GC_MINOR: (Nursery full) time 3.86ms, stw 4.78ms promoted 202K major size: 14592K in use: 12276K los size: 2048K in use: 519K  
[0:] END OF => MapOnPaintSurface (00:00:00.1681172)
[0:] END OF => MapOnPaintSurface (00:00:00.1327508)
07-15 23:00:54.351 I/harpnado.sampl(27977): Explicit concurrent copying GC freed 4978(190KB) AllocSpace objects, 0(0B) LOS objects, 24% free, 18MB/24MB, paused 337us total 21.756ms  
07-15 23:00:54.351 D/Mono    (27977): GC_TAR_BRIDGE bridges 44 objects 44 opaque 0 colors 44 colors-bridged 44 colors-visible 44 xref 0 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.03ms tarjan 0.01ms scc-setup 0.06ms gather-xref 0.01ms xref-setup 0.01ms cleanup 0.07ms  
07-15 23:00:54.351 D/Mono    (27977): GC_BRIDGE: Complete, was running for 22.91ms  
07-15 23:00:54.352 D/Mono    (27977): GC_MINOR: (Nursery full) time 4.54ms, stw 5.19ms promoted 202K major size: 14800K in use: 12479K los size: 2048K in use: 519K  
[0:] END OF => MapOnPaintSurface (00:00:00.1716039)
[0:] END OF => MapOnPaintSurface (00:00:00.1298934)
07-15 23:00:54.651 I/harpnado.sampl(27977): Explicit concurrent copying GC freed 3712(186KB) AllocSpace objects, 0(0B) LOS objects, 24% free, 18MB/24MB, paused 317us total 22.228ms  
07-15 23:00:54.652 D/Mono    (27977): GC_TAR_BRIDGE bridges 46 objects 46 opaque 0 colors 46 colors-bridged 46 colors-visible 46 xref 0 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.04ms tarjan 0.04ms scc-setup 0.02ms gather-xref 0.01ms xref-setup 0.01ms cleanup 0.02ms  
07-15 23:00:54.653 D/Mono    (27977): GC_BRIDGE: Complete, was running for 23.85ms  
07-15 23:00:54.653 D/Mono    (27977): GC_MINOR: (Nursery full) time 6.72ms, stw 8.07ms promoted 202K major size: 14992K in use: 12682K los size: 2048K in use: 519K  
[0:] END OF => MapOnPaintSurface (00:00:00.1707013)

The GC collections are the real issue: I think I could use a Matthew Leibowitz here :).

The iOS experience is really similar to the Android one.

I already improved Android GC frequency by using the PictureRecorder.

var pictureRecorder = new SKPictureRecorder();  
var canvas = pictureRecorder.BeginRecording(e.Info.Rect);

// Draw all the stuff in canvas

_overlayPicture = pictureRecorder.EndRecording();

surfaceCanvas.Clear();  
surfaceCanvas.DrawPicture(_overlayPicture);

_overlayPicture.Dispose();  
pictureRecorder.Dispose();  

We could improve the reactivity by resampling the sessions: we don't really need 7k points to have a nice gradient lines. As we saw earlier, it would also give a more pleasant result.

Regarding the UWP side, the experience is awful.

UWP I'm sorry, please forgive me

I tried guys, but I did a lousy job with UWP...
First for some reason, the conversion is inaccurate: pixels are always off by some random translation.
Then when moving the map, the overlay is way behind. Maybe the Position given by the deprecated CameraChanged event is wrong.
Maybe my formula doesn't work with Bing Maps... I don't know, it needs some work and I must say I didn't have enough time to work this through...

What's next?

So... This was a massive post I must say, even for me :)
But I will continue to work on the SessionMap on several aspects:

1. Performance (I love optimization :)

I am eager to try to add the SkiaSharp overlay as a GroundOverlay, and see what happens in term of performance.
Issue is, there is no GroundOverlay for UWP...

2. UWP troubleshooting

Maybe doing its own map renderer for uwp is the way...

3. Adding more exciting case study to SessionMap

The mysterious 'MaxTime' property...

4. Adding Skia components to SkiaSharpnado and publish a nuget

Final question

Would you like to have the SkiaSharpnado.Maps released as a nuget package?

Final Thanks

Thanks again to David, Steven, and Glenn!

Huge thanks to Matthew Leibowitz who unleashed the infinite power of Xamarin.Forms with SkiaSharp.

Special thanks to Almir Vuk whose tcx files refused to deliver their secrets (those 10k runs at night looked promising :).