🕓 7 MIN As 2024 wraps up, …
Ever wondered how to visualize millions of datapoints without your app breaking a sweat?
In this tutorial, we’ll tackle a common challenge faced by many developers: how to effectively visualize massive datasets across different platforms without sacrificing performance. We’ll explore a solution that allows you to render millions of data points smoothly, whether you’re working on real-time analytics dashboards, scientific data processing tools, or any application that demands high-performance data visualization.
Throughout this guide,you’ll learn techniques for optimizing data rendering, managing memory efficiently, and creating responsive UIs even when dealing with large datasets.
By the time we’re done, you’ll have an app capable of:
First things first, let’s make sure you’re set up with Uno Platform. If you haven’t already, head over to our Get Started guide to get your environment ready.
New to ScottPlot? No worries! Scott’s got you covered with an Uno Platform Quickstart that’ll get you up to speed in no time.
For this tutorial, we’ll be working with the following packages and versions:
Our project consists of several key files:
MainPage.xaml
: Defines the UIMainPage.xaml.cs
: Contains the main logic for the applicationDataService.cs
: Handles database operationsPlotSettings.cs
: Defines the structure for storing plot settingsSeries.cs
: Defines the data modelLet’s start by visualizing our UI.
ScottPlot:WinUIPlot
controlHorizontal StackPanel containing three buttons:
┌─────────────────────────────────┐
│ Title │
├─────────────────────────────────┤
│ │
│ │
│ Data Visualization │
│ (ScottPlot:WinUIPlot) │
│ │
│ │
├─────────────────────────────────┤
│ [Add Data] [Clear] [Change Type]│
├─────────────────────────────────┤
│ Information Display │
└─────────────────────────────────┘
Let’s start by looking at the namespaces and class variables used in our main file:
using Microsoft.UI.Dispatching;
using Windows.Storage;
using SQLite;
using ScottPlot;
namespace ScottPlotDataPersistedSample
{
public sealed partial class MainPage : Page
{
// Data-related fields
private DataService _dataService;
private readonly Random _random = new();
private readonly List _cachedSeries = new();
private readonly object _seriesLock = new();
// Plot-related fields
private string currentPlotType = "SignalPlot";
private readonly string[] _plotTypes = { "SignalPlot", "SignalConst", "Heatmap", "ScatterDownsample" };
// UI-related fields
private readonly DispatcherQueue _dispatcherQueue;
// State-tracking fields
private bool _isInitialized = false;
private const int _batchSize = 5;
private int _currentBatchIndex = 0;
private int _currentChartTypeIndex = 0;
// ... rest of the class
}
}
This section sets up the foundation of our application:
_dataService
will handle our database operations._cachedSeries
stores our data in memory, protected by _seriesLock
for thread safety._plotTypes
defines the available chart types, with currentPlotType
tracking the current selection._dispatcherQueue
ensures UI updates happen on the correct thread.
public sealed partial class MainPage : Page
{
public MainPage()
{
this.InitializeComponent();
this.Loaded += MainPage_Loaded;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
}
private async void MainPage_Loaded(object sender, RoutedEventArgs e)
{
await UpdateUIStatusAsync("Loading initial data, please wait...");
try
{
await InitializeDatabaseAsync();
currentPlotType = _dataService.GetLastUsedPlotType() ?? currentPlotType;
await LoadNextDataBatchAsync();
_isInitialized = true;
await InitializePlotAsync();
UpdateStatusText(_cachedSeries.Sum(s => s.DataPoints.Length));
}
catch (Exception ex)
{
await UpdateUIStatusAsync($"Initialization failed: {ex.Message}");
}
}
private async Task InitializeDatabaseAsync()
{
_dataService = new DataService();
await _dataService.InitializeAsync();
}
}
This section demonstrates the initialization process of our application:
MainPage_Loaded
event handler.MainPage_Loaded
is marked as async
, allowing us to use await
for asynchronous operations. This ensures our UI remains responsive during potentially time-consuming initialization tasks.This approach allows for a smooth startup process, even when dealing with large datasets or slow storage systems. By using asynchronous methods, we ensure the UI thread isn’t blocked, maintaining a responsive application throughout the initialization phase.
private async Task LoadNextDataBatchAsync()
{
List nextBatch;
do
{
nextBatch = await _dataService.GetSeriesBatchAsync(_currentBatchIndex, _batchSize);
lock (_seriesLock)
{
_cachedSeries.AddRange(nextBatch);
}
_currentBatchIndex += nextBatch.Count;
await UpdateUIStatusAsync(nextBatch.Count > 0
? $"Loaded {_cachedSeries.Count} series so far..."
: $"All series loaded. Total: {_cachedSeries.Count} series.");
} while (nextBatch.Count == _batchSize);
}
This method implements lazy loading, a crucial technique for managing large datasets:
_batchSize
) rather than all at once. This approach helps manage memory usage, especially important for platforms with limited resources like mobile devices or web browsers.do-while
loop continues fetching batches until we receive fewer items than the batch size, indicating we’ve reached the end of the data._seriesLock
) when adding new data to _cachedSeries
to ensure thread safety. This is important as data loading happens asynchronously and could potentially conflict with other operations accessing the cached data.By implementing lazy loading, we can handle datasets that are too large to fit into memory all at once, making our application more scalable and efficient in its resource usage.
private async Task PlotDataAsync()
{
await Task.Run(() =>
{
lock (_seriesLock)
{
var localSeriesList = _cachedSeries.ToList();
_dispatcherQueue.TryEnqueue(() =>
{
WinUIPlot1.Plot.Clear();
var palette = new ScottPlot.Palettes.Category10();
foreach (var series in localSeriesList)
{
switch (currentPlotType)
{
case "SignalPlot":
var signalPlot = WinUIPlot1.Plot.Add.Signal(series.DataPoints);
signalPlot.Color = palette.GetColor(localSeriesList.IndexOf(series));
break;
case "SignalConst":
var signalConstPlot = WinUIPlot1.Plot.Add.SignalConst(series.DataPoints);
signalConstPlot.LineWidth = 2;
signalConstPlot.Color = palette.GetColor(localSeriesList.IndexOf(series));
break;
case "Heatmap":
if (localSeriesList.Count > 0)
{
double[,] heatmapData = GenerateHeatmapData(series.DataPoints);
WinUIPlot1.Plot.Add.Heatmap(heatmapData);
}
break;
case "ScatterDownsample":
var xs = Enumerable.Range(0, series.DataPoints.Length).Select(x => (double)x).ToArray();
var scatterPlot = WinUIPlot1.Plot.Add.Scatter(xs, series.DataPoints);
scatterPlot.Color = palette.GetColor(localSeriesList.IndexOf(series));
break;
}
}
WinUIPlot1.Plot.Axes.AutoScale();
WinUIPlot1.Refresh();
});
}
});
}
This method is the core of our data visualization process:
Task.Run
to perform the plotting operation on a background thread, preventing UI freezes during complex calculations._seriesLock
ensures thread-safe access to our data._dispatcherQueue.TryEnqueue
call, ensuring all UI updates happen on the main thread.This flexible approach allows us to switch between different visualization types easily, catering to various data analysis needs while maintaining performance.
private async void AddRandomDataButton_Click(object sender, RoutedEventArgs e)
{
if (!_isInitialized)
{
await UpdateUIStatusAsync("Initialization in progress. Please wait.");
return;
}
var newSeries = GenerateRandomWalk(100000, _cachedSeries.Sum(s => s.DataPoints.Length));
lock (_seriesLock)
{
_cachedSeries.Add(newSeries);
}
await _dataService.AddSeriesBatchAsync(new List { newSeries });
await PlotDataAsync();
UpdateStatusText(_cachedSeries.Sum(s => s.DataPoints.Length));
GC.Collect();
}
This method handles the “Add Random Data” button click:
GC.Collect()
to prompt garbage collection. This is particularly important when dealing with large datasets, as it helps manage memory usage after significant data operations.While explicit garbage collection should be used judiciously, in this case, it helps ensure our application doesn’t consume excessive memory, especially on resource-constrained devices.
public class DataService
{
private SQLiteConnection _db;
private static StorageFolder _localFolder = Windows.Storage.ApplicationData.Current.LocalFolder;
public async Task InitializeAsync()
{
StorageFolder folder = await _localFolder.CreateFolderAsync("ScottPlotDatabase", CreationCollisionOption.OpenIfExists);
string dbPath = Path.Combine(folder.Path, "seriesData.db");
_db = new SQLiteConnection(dbPath);
_db.CreateTable();
_db.CreateTable();
}
public async Task AddSeriesBatchAsync(List seriesList)
{
await Task.Run(() =>
{
_db.RunInTransaction(() =>
{
foreach (var series in seriesList)
{
_db.Insert(series);
}
});
});
}
public async Task> GetSeriesBatchAsync(int startIndex, int batchSize)
{
return await Task.Run(() =>
{
return _db.Table()
.Skip(startIndex)
.Take(batchSize)
.ToList();
});
}
// Other methods omitted for brevity
}
The DataService
class manages our SQLite database operations:
InitializeAsync
sets up the SQLite database, creating necessary tables if they don’t exist.AddSeriesBatchAsync
efficiently inserts multiple series into the database using a transaction, which improves performance for batch inserts.GetSeriesBatchAsync
retrieves a batch of series from the database, supporting our lazy loading approach.By using SQLite, we achieve efficient local storage of large datasets. The use of async methods ensures database operations don’t block the UI thread, maintaining application responsiveness even during intensive I/O operations.
Create a new file DataService.cs
and add this code:
public class PlotSettings
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string? LastUsedPlotType { get; set; }
}
This simple class defines our plot settings structure:
[PrimaryKey, AutoIncrement]
attribute on Id
tells SQLite to use this as a unique, auto-incrementing primary key.LastUsedPlotType
stores the user’s last selected plot type, allowing us to restore their preference between sessions.By persisting these settings, we enhance the user experience, making the application feel more personalized and remembering user choices across different uses of the application.
Create a new file DataService.cs
and add this code:
using SQLite;
using MessagePack;
namespace ScottPlotDataPersistedSample;
[MessagePackObject]
public class Series
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string DataPointsSerialized { get; set; }
public double Origin { get; set; }
[Ignore]
[IgnoreMember]
public double[] DataPoints
{
get => MessagePackSerializer.Deserialize(Convert.FromBase64String(DataPointsSerialized));
set => DataPointsSerialized = Convert.ToBase64String(MessagePackSerializer.Serialize(value));
}
}
The Series
class is a crucial component of our application, serving as the data model for our time series data. Let’s break down its key features:
[MessagePackObject]
attribute, indicating that we’re using MessagePack for efficient serialization. MessagePack is a binary serialization format that’s faster and more compact than JSON, which is particularly beneficial when dealing with large datasets.Id
property is marked with [PrimaryKey, AutoIncrement]
attributes, allowing SQLite to manage it as a unique identifier for each series.DataPointsSerialized
). This approach allows for more efficient storage and retrieval, especially for large datasets.Origin
property stores the starting point of the series, which can be useful for certain types of data analysis or visualization.[Ignore]
for SQLite (so it’s not stored directly in the database) and [IgnoreMember]
for MessagePack (so it’s not included in the serialization).double[]
array.double[]
array into a Base64 string for storage.This design offers several advantages:
DataPoints
property is accessed, saving memory and processing time if the raw data isn’t needed.By using MessagePack and this serialization strategy, we’ve optimized our data model for both performance and storage efficiency, which is crucial when working with large datasets in a cross-platform environment.
We’ve covered a lot of ground, so let’s take a moment to recap. We’ve built a data visualization app that handles large datasets across multiple platforms—here are the key takeaways:
With these techniques, you’ll be able to build powerful, cross-platform apps that not only handle large datasets with ease but also deliver smooth performance and rich data visualization, all while keeping your users’ experience front and center.
Tags: XAML, C#, Charts, Data, SQL, ScottPlot
Uno Platform
360 rue Saint-Jacques, suite G101,
Montréal, Québec, Canada
H2Y 1P5
USA/CANADA toll free: +1-877-237-0471
International: +1-514-312-6958
Necessary cookies are absolutely essential for the website to function properly. This category only includes cookies that ensures basic functionalities and security features of the website. These cookies do not store any personal information.
Any cookies that may not be particularly necessary for the website to function and is used specifically to collect user personal data via analytics, ads, other embedded contents are termed as non-necessary cookies. It is mandatory to procure user consent prior to running these cookies on your website.
Uno Platform 5.2 LIVE Webinar – Today at 3 PM EST – Watch