Developing advanced MATLAB® visualizations often involves managing multiple low-level graphics objects. This is especially the case for applications containing graphics that update dynamically. Such applications may require time-consuming programming.
A chart object provides a high-level application programming interface (API) for creating custom visualizations. A chart not only provides a convenient visualization API for your end users; it also removes the need for the user to implement low-level graphics programming.
MATLAB includes an object-oriented framework for developing custom charts via the following container superclasses:
matlab.graphics.chartcontainer.ChartContainer (introduced in R2019b)
matlab.ui.componentcontainer.ComponentContainer (introduced in R2020b)
This article provides a step-by-step guide, with design patterns and best practices, to creating and implementing a custom chart using this framework. The steps are illustrated with an example scatter plot containing the best-fit line. Topics include:
Writing a standard chart template
Writing the chart’s setup and update methods
Encapsulating data and graphics
Providing a high-level API for end users
Including interactive controls
Chart Examples
Several charts are available in MATLAB, including the heatmap chart, which visualizes matrix values overlaid on colored grid squares, and the geobubble chart, which provides a quick way to plot discrete data points on a map (Figure 1).
Figure 1. The heatmap and geobubble charts.
In addition, we've created several application-specific charts (Figure 2). You can download these charts, together with the MATLAB code used in this article, from File Exchange.
Figure 2. Custom charts available for download on File Exchange.
Creating a 2D Scatter Plot
Let's say we want to create a 2D scatter plot containing the corresponding line of best fit (Figure 3). We could use the scatter function to visualize the discrete (x,y) data points and the fitlm function from Statistics and Machine Learning Toolbox™ to compute the best-fit line.
rng("default")
x = randn(1000, 1);
y = 2*x + 1 + randn(size(x));
s = scatter(x, y, 6, "filled", "MarkerFaceAlpha", 0.5);
m = fitlm(x, y);
hold on
plot(x, m.Fitted, "LineWidth", 2)
Figure 3. Best-fit line and the underlying scattered data.
The code above is sufficient for static visualizations. However, if the application requires the data to be dynamically modifiable, then we encounter several challenges:
If we replace the XData or YData with a new array of the same length as the current XData, the best-fit line is not dynamically updated (Figure 4).
s.XData = s.XData + 4;
Figure 4. The best-fit line is not updated after changing the XData of the scatter plot.
The Scatter object s issues a warning, and performs no graphics update, if either of its data properties (XData or YData) is set to an array that is longer or shorter than the current array.
s.XData = s.XData(1:500);
We can resolve these challenges, and others, by designing a chart, which we name ScatterFit.
Structuring the Chart Code: Function or Class?
A function encapsulates the code as a reusable unit and lets you create multiple charts without duplicating the code.
function scatterfit(varargin)
% Ensure 2 or 3 inputs.
narginchk(2, 3)
% We support the calling syntax scatterfit(x, y) or
% scatterfit(f, x, y), where f is the parent graphics.
switch nargin
case 2
f = gcf;
x = varargin{1};
y = varargin{2};
otherwise % case 3
f = varargin{1};
x = varargin{2};
y = varargin{3};
end % switch/case
% Create the chart axes and scatter plot.
ax = axes("Parent", f);
scatter(ax, x, y, 6, "filled")
% Compute and create the best-fit line.
m = fitlm(x, y);
hold(ax, "on")
plot(ax, x, m.Fitted, "LineWidth", 2)
hold(ax, "off")
end % scatterfit function
Note that this function requires the two data inputs (x and y). You can specify the graphics parent f (for example, a figure) as the first input argument.
scatterfit(x, y) specifies the two data inputs.
scatterfit(f, x, y) specifies the graphics parent and the data.
In the first case, the function exhibits autoparenting behavior — that is, a figure for the chart will be created automatically.
Using a function to create a chart has some drawbacks:
You cannot modify the data after the chart has been created.
To change the chart data, you need to call the function again to recreate the chart.
It's difficult for the end user to locate configurable chart parameters (e.g., labels and decorative graphics properties such as color, line style, etc).
Implementing the chart as a class has all the benefits of code encapsulation and reusability that the function provides while also letting you modify the chart without needing to recreate it.
Choosing the Chart Superclass: ChartContainer or ComponentContainer?
A chart must be implemented as a handle class so that it can be modified in place. For consistency with MATLAB graphics objects, charts should support the get/set syntax for properties in addition to the standard dot notation. Both ChartContainer and ComponentContainer are handle classes and provide support for the get/set syntax, which means that you can derive your custom chart from one of these superclasses.
classdef ScatterFit < matlab.ui.componentcontainer.ComponentContainer
As a result, for any property, the syntax shown in Table 1 is automatically supported.
Table 1. Access and modification syntax for chart properties.
Select a superclass based on the requirements of your chart. If the chart does not require interactive user-facing controls such as buttons, dropdown menus, and checkboxes, then derive the chart from ChartContainer; otherwise, use ComponentContainer. This is because the chart container superclass provides a tiled layout as the top-level graphics object, and this object can contain axes but not user controls. The top-level graphics object associated with a component container is a panel-like object, which supports both axes and user controls.
Note that the framework superclasses automatically manage the chart life cycle, guaranteeing the following behavior:
When the chart graphics are deleted (for example, by closing the main figure window), the chart object is deleted.
When the chart object is deleted (for example, when it goes out of scope or when its handle is deleted), the chart graphics are deleted.
The framework superclasses support the use of name-value pairs for all chart input arguments. This means that no input arguments need to be specified when creating a chart, and all inputs are optional.
Writing the Chart setup and update Methods
We now need to implement two special methods, both of which are required by the framework superclasses:
setup: called automatically when the chart is created
update: called automatically when the user modifies certain chart properties
These methods must have protected access in our chart class since they have this attribute in the superclass.
methods (Access = protected)
function setup(obj)
end % setup
function update(obj)
end % update
end % methods (Access = protected)
Let’s look at the setup method. This is the function within the class definition where we initialize the chart. A good place to start is to copy the code from the scatterfit function to the setup method. We then make the following modifications to support the required chart behavior:
Parent graphics. Unlike the approach described above in the scatterfit function, if no Parent input is specified, then we do not automatically create one for the chart. Note that this behavior is different from that of convenience functions such as plot and scatter, which exhibit autoparenting. Within the setup method, we create our main graphics object (such as an axes, panel, or layout) and assign its parent property to the top-level graphics object provided by the superclass. The getLayout method of the ChartContainer superclass returns a reference to the top-level tiled layout. For ComponentContainer charts, we can simply assign the graphics parent property to the object itself.
obj.Axes = axes("Parent", obj.getLayout()); % ChartContainer
obj.Axes = axes("Parent", obj); % ComponentContainer
If Parent is specified as an input argument, then it will be set automatically by the superclass,
together with any other name-value pairs supplied during chart creation. The superclass will assign
the user-specified parent as the parent of the top-level graphics object.
Chart graphics. We create and store any graphics objects required by the chart. Most charts will require an axes object together with some axes contents such as line or patch objects. In the ScatterFit chart, we need a Scatter object and a Line object.
obj.ScatterSeries = scatter(obj.Axes, NaN, NaN);
obj.BestFitLine = line(obj.Axes, NaN, NaN);
Note that we initialize these graphics with their data properties set to NaN. If the user has
specified the XData and/or YData on construction, then we defer updating the scatter plot and
best-fit line to the corresponding set methods (discussed later). This coding practice ensures that
any errors caused by the user when specifying the name-value pairs will be caught and handled
separately.
Graphics configuration. We configure the chart graphics by setting any required properties. For example, we may create annotations such as labels or titles, set a specific view of an axes, add a grid, or adjust the color, style or width of a line.
Whenever practical, we use primitive objects (Table 2) to create the chart graphics, because high-level convenience functions reset many existing axes properties when called. However, there are exceptions to this principle: within ScatterFit, we use the non-primitive function scatter to create the graphics object as it supports subsequent changes to individual marker sizes and colors (whereas a single line object does not).
Table 2. Examples of primitive and high-level graphics functions.
We’ll return to the chart’s update method later.
Encapsulating Chart Data and Graphics
In most charts, the underlying graphics comprise at least one axes object together with their contents (for example, line or surface objects) or axes peer objects (for example, legends or colorbars). The chart also maintains internal data properties to ensure that the public properties are presented correctly to the end user. We store the underlying graphics and internal data as private chart properties. For example, the ScatterFit chart maintains the following private properties.
properties (Access = private)
% Internal storage for the XData property.
XData_ = double.empty(0, 1)
% Internal storage for the YData property.
YData_ = double.empty(0, 1)
% Logical scalar specifying whether a computation is required.
ComputationRequired = false()
end % properties (Access = private)
We use the naming convention XData_ to indicate that this is the private, internal version of the chart data. The corresponding public data property visible to the user will be named XData.
properties (Access = private, Transient, NonCopyable)
% Chart axes.
Axes(1, 1) matlab.graphics.axis.Axes
% Scatter series for the (x, y) data.
ScatterSeries(1, 1) matlab.graphics.chart.primitive.Scatter
% Line object for the best-fit line.
BestFitLine(1, 1) matlab.graphics.primitive.Line
end % properties (Access = private, Transient, NonCopyable)
Using private properties for internal chart data and graphics serves three main purposes.
Private properties restrict the visibility of the low-level graphics, hiding implementation details and reducing visual clutter in the chart’s API.
Access to the low-level graphics is restricted, reducing the chance of bypassing the API.
Chart data can be easily synchronized (for example, we require the XData and YData properties of ScatterFit to be related).
For internal graphics properties, it is a good practice to specify the Transient and NonCopyable attributes. These ensure that the chart object behaves correctly when it is saved to a MAT-file or copied. For additional robustness, and to enable tab completion on the graphics properties when working in the chart class, we also implement property validation.
Providing a Visualization API
One of the main reasons for designing a chart is to provide a convenient and intuitive API. We equip the ScatterFit chart with easily recognizable properties, using names consistent with existing graphics object properties (Figure 5).
Figure 5. ScatterFit chart API.
Users can access or modify these properties using the sample syntax in Table 1. The associated chart graphics update dynamically in response to property modifications. For example, changing the LineWidth property of the chart updates the LineWidth of the best-fit line.
We implement parts of the chart’s API using Dependent properties. A Dependent property is one whose value is not stored explicitly, but rather is derived from other properties in the class. In a chart, the Dependent properties depend on private properties such as the low-level graphics or internal data properties.
To define a Dependent property, we first declare its name in a properties block with attribute Dependent. This indicates that the property’s value depends on other properties within the class.
properties (Dependent)
% Chart x-data.
XData(:, 1) double {mustBeReal}
% Chart y-data.
YData(:, 1) double {mustBeReal}
end % properties (Dependent)
We also need to specify how the property depends on the other class properties by writing the corresponding get method. This method returns a single output argument – namely, the value of the Dependent property. In the ScatterFit chart, the XData property of the chart (part of the chart’s public interface) is simply the underlying XData_ property, which is stored internally as a private property of the chart.
function value = get.XData(obj)
value = obj.XData_;
end % get.XData
Each data property also requires a set method. This assigns the user-specified value to the correct internal chart property and triggers any necessary graphics updates.
For the ScatterFit chart, we support dynamic modifications (including length changes) to the data properties (XData and YData). When the user sets the (public) XData of the chart, we either pad or truncate the opposite (private) data property YData_, depending on whether the new data vector is respectively longer or shorter than the existing data. Recall that this set method will be invoked on construction if the user has specified XData when creating the chart.
function set.XData(obj, value)
% Mark the chart for an update.
obj.ComputationRequired = true();
% Decide how to modify the chart data.
nX = numel(value);
nY = numel(obj.YData_);
if nX < nY % If the new x-data is too short then truncate the chart
y-data.
obj.YData_ = obj.YData_(1:nX);
else
% Otherwise, if nX >= nY, then pad the y-data.
obj.YData_(end+1:nX, 1) = NaN;
end % if
% Set the internal x-data.
obj.XData_ = value;
end % set.XData
Note that the chart’s update method is invoked automatically whenever the user sets a public property. To avoid unnecessary and time-consuming calculations, we use a private, internal logical property ComputationRequired to record, in the set methods, whether a full update is necessary.
Public API properties that do not require new computation when they change do not need a get or set method. Instead, we simply refresh the corresponding internal objects at the end of the update method. Typically, public API properties include decorative and cosmetic aspects of the chart such as colors, line widths, and styles, which are inexpensive to update.
In the ScatterFit chart, the update method contains the code necessary to set the new data in the Scatter object, recompute the best-fit line and set the new data in the corresponding Line object.
function update( obj )
if obj.ComputationRequired
% Update the scatter series with the new data.
set(obj.ScatterSeries, "XData", obj.XData_, "YData", obj.YData_)
% Obtain the new best-fit line.
m = fitlm(obj.XData_, obj.YData_);
% Update the best-fit line graphics.
[~, posMin] = min(obj.XData_);
[~, posMax] = max(obj.XData_);
set(obj.BestFitLine, "XData", obj.XData_([posMin, posMax]),
"YData", m.Fitted([posMin, posMax]))
% Mark the chart clean.
obj.ComputationRequired = false();
end % if
% Refresh the chart's decorative properties.
set(obj.ScatterSeries, "CData", obj.CData, "SizeData",
obj.SizeData)
end % update
We implement the set method for YData in an identical way, switching the roles of the X/YData properties.
To create a rich API appropriate for end users, we implement a broad set of public properties. Note that standard properties such as Parent, Position, Units, and Visible are all inherited from the superclass and do not require additional implementation in the chart.
Adding Chart Annotation Methods
In the API, we provide familiar and easy-to-use methods for annotating the chart. These annotation methods overload (have the same name as) the corresponding high-level graphics decoration function. To use these methods, the user provides a reference to the chart as the first input argument, followed by inputs to the decoration function.
xlabel(SF, "x-data", "FontSize", 12)
If supported by the decoration function, the annotation method can also be called with an output to return a reference to the graphics object for further customization. For example, the xlabel function returns a text object.
xl = xlabel(SF, "x-data");
To support name-value pairs and output arguments, it is convenient to use the cell arrays varargin and varargout. The syntax varargin{:} produces a comma-separated list of the input arguments. We determine the number of outputs from the caller using nargout. To handle a variable number of output arguments (typically, 0 or 1 for these methods) we use the syntax [varargout{1:nargout}] when invoking the decoration function. A typical annotation method has the following structure:
function varargout = xlabel(obj, varargin)
[varargout{1:nargout}] = xlabel(obj.Axes, varargin{:});
end % xlabel
Including Interactive Controls in Charts
In addition to the chart’s API, we can include controls that provide end users with options for chart interaction and modification (Figure 6).
Figure 6. Examples of interactive chart controls.
We initialize these controls in the chart setup method, using components for app building. Each control has a callback function, implemented as a private method. This method has three input arguments:
The chart object.
A reference to the source object (the object responsible for triggering the callback)—in this case, the source object is the corresponding user control.
Event data. This is an object automatically passed to the callback function by MATLAB when the user interacts with the control. The event data object contains additional information about the event.
For example, consider the callback function for the checkbox controlling the visibility of the best-fit line. This function toggles the visibility of the underlying line object based on the value of the checkbox.
function toggleLineVisibility(obj, s, ~)
%TOGGLELINEVISIBILITY Toggle the visibility of the best-fit line.
obj.BestFitLine.Visible = s.Value;
end % toggleLineVisibility
The values of each control must be synchronized with the corresponding chart property. To achieve this, we equip the chart property with the Dependent attribute and then implement its get and set methods. Note that in addition to updating the internal graphics object, the set method must also update the value of the control object.
The code corresponding to the best-fit line visibility is shown below. To ensure compatibility between the property and checkbox values, we convert the property to the matlab.lang.OnOffSwitchState type. This type supports any compatible syntax for representing true and false values, such as 1 and 0, as well as "on" and "off".
properties (Dependent)
% Visibility of the best-fit line.
LineVisible(1, 1) matlab.lang.OnOffSwitchState
end % properties (Dependent)
function value = get.LineVisible(obj)
value = obj.BestFitLine.Visible;
end % get.LineVisible
function set.LineVisible(obj, value)
% Update the property.
obj.BestFitLine.Visible = value;
% Update the check box.
obj.BestFitLineCheckBox.Value = value;
end % set.LineVisible
Integrating Charts with App Designer
As of MATLAB R2021a, charts developed using the ComponentContainer superclass can be integrated with App Designer (Figure 7). With App Designer you can share charts with end users by creating metadata. The installed chart will then appear in the user’s App Designer Component Library, where it can be used interactively in the canvas like any other component.
Figure 7. Custom chart integrated with App Designer.
Summary
In this article, we described design patterns and best practices for the implementation of custom charts, using the ScatterFit chart as an example. Many common visualization tasks, especially those that require dynamic graphics, can be performed using an appropriate chart. Designing and creating a chart requires up-front development time and effort, but charts can substantially simplify many visualization workflows.
Source: MATLAB
The Tech Platform
Comments