Featured image of post Real-time charts with Blazor, SignalR and ApexCharts

Real-time charts with Blazor, SignalR and ApexCharts

Streaming currency prices with SignalR

Follow me

Introduction Link to this section

SignalR is a free open-source library for ASP.NET Core that allows the server to push real-time asynchronous messages to connected clients. It is an abstraction layer on top of WebSockets, making it easier to use and providing fallback to other forms of communication when necessary (server-sent events and long polling).

In this post, I’ll show how to build a Blazor WebAssembly app that displays real-time charts from a SignalR server.

Application running

The project structure Link to this section

The project will have 4 projects, created using 2 project templates:

  • ASP.NET Core Project (for the SignalR server)
    • BlazorWasmSignalR.SignalRServer
  • Blazor WebAssembly App (ASP.NET Core Hosted)
    • BlazorWasmSignalR.Wasm.Client (Blazor WASM)
    • BlazorWasmSignalR.Wasm.Server (ASP.NET Core Host)
    • BlazorWasmSignalR.Wasm.Shared (Common components)

Project structure

The Backend - SignalR Server Link to this section

SignalR is part of ASP.NET Core. To use it, we just need to configure it in our Startup.cs or Program.cs (if using top-level statements):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//Add SignalR services
builder.Services.AddSignalR();

...

var app = builder.Build();

...

//Map our Hub
app.MapHub<RealTimeDataHub>("/realtimedata");

app.Run();

SignalR clients connect to Hubs, which are components that define methods that can be called by the clients to send messages or to subscribe to messages from the server.

In this demo, I’ve created a Hub with two methods that return, each, a stream of data simulating a currency price change. When the client calls the methods, it will add the client’s ConnectionId to a list of listeners and return the stream of data to the client. After that, the RealTimeDataStreamWriter service will write every currency price change to the listeners streams.

The OnDisconnectedAsync is called when a client disconnects and removes the client from the listeners list.

⚠️ This is a naive implementation that is not horizontally scalable. It is for demo purposes only. Also, for scaling SignalR horizontally, a Redis dataplane must be configured.

RealTimeDataHub implementation Link to this section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using BlazorWasmSignalR.SignalRServer.BackgroundServices;
using BlazorWasmSignalR.Wasm.Shared;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Channels;

namespace BlazorWasmSignalR.SignalRServer.Hubs;

public class RealTimeDataHub : Hub
{
    private readonly RealTimeDataStreamWriter _realTimeDataStreamWriter;

    public RealTimeDataHub(RealTimeDataStreamWriter realTimeDataStreamWriter)
    {
        _realTimeDataStreamWriter = realTimeDataStreamWriter;
    }

    public ChannelReader<CurrencyStreamItem> CurrencyValues(CancellationToken cancellationToken)
    {
        var channel = Channel.CreateUnbounded<CurrencyStreamItem>();

        _realTimeDataStreamWriter.AddCurrencyListener(Context.ConnectionId, channel.Writer);

        return channel.Reader;
    }

    public ChannelReader<DataItem> Variation(CancellationToken cancellationToken)
    {
        var channel = Channel.CreateUnbounded<DataItem>();

        _realTimeDataStreamWriter.AddVariationListener(Context.ConnectionId, channel.Writer);

        return channel.Reader;
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        _realTimeDataStreamWriter.RemoveListeners(Context.ConnectionId);

        await base.OnDisconnectedAsync(exception);
    }
}

RealTimeDataStreamWriter implementation Link to this section

This service keeps a list of clients that subscribed to receive price changes and simulates a price change every second.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
using BlazorWasmSignalR.Wasm.Shared;
using System.Security.Cryptography;
using System.Threading.Channels;

namespace BlazorWasmSignalR.SignalRServer.BackgroundServices;

public class RealTimeDataStreamWriter
{
    private readonly Dictionary<string, ChannelWriter<CurrencyStreamItem>> _currencyWriters;
    private readonly Dictionary<string, ChannelWriter<DataItem>> _variationWriters;
    private readonly Timer _timer = default!;

    private int _currentVariationValue = 50;
    private decimal _currentYenValue = RandomNumberGenerator.GetInt32(1, 3);
    private decimal _currentEuroValue = RandomNumberGenerator.GetInt32(1, 3);

    public RealTimeDataStreamWriter()
    {
        _timer = new(OnElapsedTime, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));

        _currencyWriters = new();
        _variationWriters = new();
    }

    public void AddCurrencyListener(string connectionId, ChannelWriter<CurrencyStreamItem> channelWriter)
    {
        _currencyWriters[connectionId] = channelWriter;
    }

    public void AddVariationListener(string connectionId, ChannelWriter<DataItem> channelWriter)
    {
        _variationWriters[connectionId] = channelWriter;
    }

    public void RemoveListeners(string connectionId)
    {
        _currencyWriters.Remove(connectionId);
        _variationWriters.Remove(connectionId);
    }

    public void Dispose()
    {
        _timer?.Dispose();
    }

    private void OnElapsedTime(object? state)
    {
        SendCurrencyData();
        SendVariationData();
    }

    private void SendCurrencyData()
    {
        var date = DateTime.Now;

        var yenDecimals = RandomNumberGenerator.GetInt32(-20, 20) / 100M;
        var euroDecimals = RandomNumberGenerator.GetInt32(-20, 20) / 100M;

        _currentYenValue = Math.Max(0.5M, _currentYenValue + yenDecimals);
        _currentEuroValue = Math.Max(0.5M, _currentEuroValue + euroDecimals);

        var currencyStreamItem = new CurrencyStreamItem()
        {
            Minute = date.ToString("hh:mm:ss"),
            YenValue = _currentYenValue,
            EuroValue = _currentEuroValue
        };

        foreach(var listener in _currencyWriters)
        {
            _ = listener.Value.WriteAsync(currencyStreamItem);
        }
    }

    private void SendVariationData()
    {
        var min = Math.Max(0, _currentVariationValue - 20);
        var max = Math.Min(100, _currentVariationValue + 20);

        var variationValue = new DataItem(DateTime.Now.ToString("hh:mm:ss"),
            RandomNumberGenerator.GetInt32(min, max));

        _currentVariationValue = (int)variationValue.Value;

        foreach (var listener in _variationWriters)
        {
            _ = listener.Value.WriteAsync(variationValue);
        }
    }
}

ApexCharts for Blazor Link to this section

ApexCharts is a free open-source JavaScript library to generate interactive and responsive charts. It has a wide range of chart types. It is the best free library that I found for working with real-time charts, with fluent animations.

ApexCharts for Blazor is a wrapper library for working with ApexCharts in Blazor. It provides a set of Blazor components that makes it easier to use the charts within Blazor applications.

ApexCharts for Blazor examples

The Frontend - Blazor WebAssembly Link to this section

In the Blazor WebAssembly project, we need to install the Blazor-ApexCharts and Microsoft.AspNetCore.SignalR.Client nuget packages:

dotnet add package Blazor-ApexCharts
dotnet add package Microsoft.AspNetCore.SignalR.Client

Adding the charts Link to this section

In the main page, we’ll have two charts:

  • One line chart with Yen and Euro price updates;
  • One gauge chart with a variation value.

To render a chart using ApexCharts for Blazor, we use the ApexChart component and one ApexPointSeries for each series.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<ApexChart TItem="DataItem"
           Title="Currency Exchange Rates in USD"
           Options="@_lineChartOptions"
           @ref="_lineChart">

    <ApexPointSeries TItem="DataItem"
                     Items="_yenSeries"
                     Name="Yen"
                     SeriesType="SeriesType.Line"
                     XValue="@(e => e.Minute)"
                     YAggregate="@(e => e.Sum(e => e.Value))" />

    <ApexPointSeries TItem="DataItem"
                     Items="_euroSeries"
                     Name="Euro"
                     SeriesType="SeriesType.Line"
                     XValue="@(e => e.Minute)"
                     YAggregate="@(e => e.Sum(e => e.Value))" />
</ApexChart>

ℹ️ The @ref attribute defines a variable to access the ApexChart object. It will be used to update the chart when new values arrive.

The Options property receives a ApexChartOptions<DataItem> where we can customize our chart. In this sample, I’m:

  • Enabling animations and setting their speed to 1 second;
  • Disabling the chart toolbar and zoom;
  • Locking the X axis in 12 elements. Older values are pushed out of the chart;
  • Fixing the Y axis in the range of 0 to 5.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private ApexChartOptions<DataItem> _lineChartOptions = new ApexChartOptions<DataItem>
{
    Chart = new Chart
    {
        Animations = new()
        {
            Enabled = true,
            Easing = Easing.Linear,
            DynamicAnimation = new()
            {
                Speed = 1000
            }
        },
        Toolbar = new()
        {
            Show = false
        },
        Zoom = new()
        { 
            Enabled = false
        }
    },
    Stroke = new Stroke { Curve = Curve.Straight },
    Xaxis = new()
    {
        Range = 12
    },
    Yaxis = new()
    {
        new()
        {
            DecimalsInFloat = 2,
            TickAmount = 5,
            Min = 0,
            Max = 5
        }
    }
};

💡 The options documentations is in the ApexCharts docs.

RealtimeCharts.razor implementation Link to this section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@page "/"
@using BlazorWasmSignalR.Wasm.Shared
@using System.Security.Cryptography;

<PageTitle>Real-time charts in Blazor WebAssembly</PageTitle>

<h1>Real-time charts in Blazor WebAssembly</h1>

<div class="chart-container">
    <div class="radial-chart">
        <ApexChart TItem="DataItem"
                   Title="Transactions"
                   Options="@_radialChartOptions"
                   @ref="_radialChart">

            <ApexPointSeries TItem="DataItem"
                             Items="_radialData"
                             SeriesType="SeriesType.RadialBar"
                             Name="Variation"
                             XValue="@(e => "Variation")"
                             YAggregate="@(e => e.Average(e => e.Value))" />
        </ApexChart>
    </div>

    <div class="line-chart">
        <ApexChart TItem="DataItem"
                   Title="Currency Exchange Rates in USD"
                   Options="@_lineChartOptions"
                   @ref="_lineChart">

            <ApexPointSeries TItem="DataItem"
                             Items="_yenSeries"
                             Name="Yen"
                             SeriesType="SeriesType.Line"
                             XValue="@(e => e.Minute)"
                             YAggregate="@(e => e.Sum(e => e.Value))" />

            <ApexPointSeries TItem="DataItem"
                             Items="_euroSeries"
                             Name="Euro"
                             SeriesType="SeriesType.Line"
                             XValue="@(e => e.Minute)"
                             YAggregate="@(e => e.Sum(e => e.Value))" />
        </ApexChart>
    </div>
</div>

Connecting to the SignalR stream Link to this section

To connect to a SignalR stream, we create a connection to the Hub using the HubConnectionBuilder class and open the connection using the StartAsync method of the HubConnection class.

Then, we subscribe to the stream using the StreamAsChannelAsync method, passing the stream name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var connection = new HubConnectionBuilder()
    .WithUrl(_configuration["RealtimeDataUrl"]!) //https://localhost:7086/realtimedata
    .Build();

await connection.StartAsync();

var channelCurrencyStreamItem = await connection
    .StreamAsChannelAsync<CurrencyStreamItem>("CurrencyValues");

var channelVariation = await connection
    .StreamAsChannelAsync<DataItem>("Variation");

ℹ️ Note that one connection can be used to subscribe to many streams.

Updating the chart values in real-time Link to this section

To read the data from the stream, I use the method WaitToReadAsync of the ChannelReader class to wait for new messages and then loop through them with the TryRead method.

Then, I add the values to the series and call the UpdateSeriesAsync method of the chart to force a re-render.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private async Task ReadCurrencyStreamAsync(ChannelReader<CurrencyStreamItem> channelCurrencyStreamItem)
{
    // Wait asynchronously for data to become available
    while (await channelCurrencyStreamItem.WaitToReadAsync())
    {
        // Read all currently available data synchronously, before waiting for more data
        while (channelCurrencyStreamItem.TryRead(out var currencyStreamItem))
        {
            _yenSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.YenValue));
            _euroSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.EuroValue));

            await _lineChart.UpdateSeriesAsync();
        }
    }
}

⚠️ Because I want the updates to be asynchronous, I do not await on the ReadCurrencyStreamAsync and ReadVariationStreamAsync methods.

Here is the complete code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
private readonly IList<DataItem> _yenSeries = new List<DataItem>();
private readonly IList<DataItem> _euroSeries = new List<DataItem>();
private ApexChart<DataItem> _lineChart = default!;
private ApexChart<DataItem> _radialChart = default!;
private ApexChart<DataItem> _lineChart = default!;

protected override async Task OnInitializedAsync()
{
    _radialData = new DataItem[1] {
        new(DateTime.Now.ToString("mm:ss"), 0)
    }; //Initialize the data for the radial chart

    var connection = new HubConnectionBuilder()
        .WithUrl(_configuration["RealtimeDataUrl"]!)
        .Build();

    await connection.StartAsync();

    var channelCurrencyStreamItem = await connection
        .StreamAsChannelAsync<CurrencyStreamItem>("CurrencyValues");

    var channelVariation = await connection
        .StreamAsChannelAsync<DataItem>("Variation");

    _ = ReadCurrencyStreamAsync(channelCurrencyStreamItem);
    _ = ReadVariationStreamAsync(channelVariation);
}

private async Task ReadCurrencyStreamAsync(ChannelReader<CurrencyStreamItem> channelCurrencyStreamItem)
{
    // Wait asynchronously for data to become available
    while (await channelCurrencyStreamItem.WaitToReadAsync())
    {
        // Read all currently available data synchronously, before waiting for more data
        while (channelCurrencyStreamItem.TryRead(out var currencyStreamItem))
        {
            _yenSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.YenValue));
            _euroSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.EuroValue));

            await _lineChart.UpdateSeriesAsync();
        }
    }
}

private async Task ReadVariationStreamAsync(ChannelReader<DataItem> channelVariation)
{
    // Wait asynchronously for data to become available
    while (await channelVariation.WaitToReadAsync())
    {
        // Read all currently available data synchronously, before waiting for more data
        while (channelVariation.TryRead(out var variation))
        {
            _radialData[0] = variation;

            await _radialChart.UpdateSeriesAsync();
        }
    }
}

Inspecting the messages Link to this section

Going into the browser’s developer tools, we can see the messages in the WebSocket connection:

Full source code Link to this section

GitHub repository

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy