ChartContainer is broken?

29 visualizaciones (últimos 30 días)
Jan Kappen
Jan Kappen el 9 de Feb. de 2021
Comentada: Benjamin Kraus el 3 de Mzo. de 2025 a las 18:29
Hi all,
Since R2020a, class properties, provided by the class constructor are assigned after the setup method runs, see https://www.mathworks.com/help/matlab/ref/matlab.graphics.chartcontainer.chartcontainer.setup.html#mw_892e1871-5986-41cf-b037-396fa9a9adbf
That means, I can't create plots that rely somehow on external information, provided by the user via constructor. An easy example would be this:
The user provides a timetable. Each column should be plotted to its own subplot:
classdef BrokenChartClass < matlab.graphics.chartcontainer.ChartContainer
properties
Data timetable
end
properties (Access = private)
NumSubplots = 1
DataAxes
DataLines
end
methods
function obj = BrokenChartClass(T)
arguments
T timetable
end
obj@matlab.graphics.chartcontainer.ChartContainer();
obj.NumSubplots = length(T.Properties.VariableNames); % create as many subplots as columns in data table
obj.Data = T;
end
end
methods (Access = protected)
function setup(obj)
tcl = getLayout(obj);
obj.DataAxes = arrayfun(@(n)nexttile(tcl, n), 1:obj.NumSubplots);
obj.DataLines = arrayfun(@(hax)plot(hax, NaT, NaN), obj.DataAxes);
end
function update(obj)
% Extract the time data from the table.
tbl = obj.Data;
t = tbl.Properties.RowTimes;
for k = 1:length(obj.DataLines)
set(obj.DataLines(k), 'XData', t, 'YData', tbl{:,k})
end
end
end
end
%% test via
T = timetable((datetime():seconds(1):datetime+hours(1))',randn(3601,1),10*randn(3601,1)); % 3601 rows, 2 data cols
head(T)
BrokenChartClass(T(:,1)); % -> one subplot, makes sense
BrokenChartClass(T(:,1:2)); % -> one subplot (uses default value of NumSubplots), not as expected but documented
Could you please help me understanding this concept? Calling setup before assigning the properties makes it impossible to use it. I always have to create another mySetup function to be called in update which requires some flag like bool IsInitialized or so.
I can't believe that's the purpose of this class. What am I missing?
Thanks!
  3 comentarios
Jörn Froböse
Jörn Froböse el 16 de Oct. de 2022
Same thing for me. Did you find any better solution than this workaround with mySetup?
Jan Kappen
Jan Kappen el 31 de En. de 2025
@Jörn Froböse check out the answers from Benjamin below, they helped me a lot to understand the concept.

Iniciar sesión para comentar.

Respuesta aceptada

Benjamin Kraus
Benjamin Kraus el 29 de En. de 2025
Editada: Benjamin Kraus el 29 de En. de 2025
@Jan Kappen: This is a question that frequently comes up. Let me try to explain our reasoning for making this change.
One of our goals with matlab.graphics.chartcontainer.ChartContainer was to make it easy for users to write charts that behave like built-in charts and work in the graphics ecosystem like other charts.
One aspect of our built-in charts is that (for improved performace and to support saving to FIG-files), you can change the data in your property after the object/chart has been created. You can also query the data back from the chart (most often to modify it, such as appending new data).
In the class you defined above, the Data property is a user-settable property, but the chart, as written above, won't work if the user changes the timetable stored in Data to have a different number of variables after creating the chart:
t = datetime()+hours(1:10)';
t1 = array2timetable(rand(10),'RowTimes', t);
t2 = array2timetable(rand(10,5),'RowTimes', t);
b = BrokenChartClass(t1);
b.Data = t2;
With the code above, you would set NumSubPlots to 10 within the constructor then you would set both DataAxes and DataLines within setup. This is only happening once when the chart is first created, and doesn't update when the Data changes. That means that within update you would get an index-out-of-bounds error because your table is narrower than 10. In addition, the number of axes and lines created will be incorrect. If you were to set Data to a table with 20 variables, your code as written would ignore all but the first 10 variables.
If a property is designed to be set by a user, it should be changable by the user at any time, including after the chart was first created.
There are two ways to allow the user to change the Data property without breaking the chart:
Option 1 - Not recommended (because it is more complicated): You can add a set.Data method. That method will be called any time the Data property is set. Within that method, you can update the values of NumSubPlots and DataAxes and DataLines based on the new value. This is not the recommended pattern for two reasons:
  • It causes complicated order-dependency issues between properties, which makes the logic in the code harder to follow and also often breaks saving and loading the chart. If you want to use this approach, you can resolve most of the save issues by making the NumSubPlots and DataAxes and DataLines transient properties.
  • It prevents the graphics system from automatically optimizing the performance of your chart. set.Data will run any time the user sets the Data property, but update will only run when MATLAB goes idle or you call drawnow. Imagine a user who is iteratively setting values in the Data property (see the code below). In that example, set.Data will run 10 times, but update will only run once. For that reason, we recommend using update instead (option 2).
t = datetime()+hours(1:10)';
t1 = array2timetable(rand(10),'RowTimes', t);
b = BrokenChartClass(t1);
for ii = 1:10
b.Data{1,ii} = b.Data{1,ii}+1;
end
Option 2 - Recommended: With update, check whether the number of variables in the table matches the current value of NumSubPlots. If they are different, update NumSubPlots and DataAxes and DataLines accordingly. This is the recommended pattern.
Note that neither of those approaches relied on an IsInitialized state on the chart, because that just recreates the same problem as-if you were to have run the code within setup.
What we found when people were writing charts is that in their first draft of the chart they would do a bunch of setup operations within setup, leveraging the user provided name/value pairs. Then they would discover that the chart doesn't support changing the data after the chart is created (and that saving and loading the chart is broken) and add duplicate code to the update method to do the same operation (or typically a subset of the operation) again. In reality, the code never should have been in setup in the first place.
The general rule of thumb I apply is that:
  • setup should only be used to do setup that will never change in the chart. Because users of the chart can change property values, setup should never rely on user-settable property values (or property values that could be changed indirectly by users, such as NumSubplots).
  • update should be used for anything that could change, such as if the user sets a property value.
By setting the name/value pairs after calling setup, we are encouraging people who are writing charts to follow that rule of thumb by not allowing access to the name/value pairs until after setup has finished.
We actually include an example chart in our documentation that shows the recommended pattern for adjusting the number of lines to update based on the data: Optimized Chart Class for Displaying Variable Number of Lines. I recommend taking a look at that example, as it can be easily adapted to create one axes per line as well.
Our goal with matlab.graphics.chartcontainer.ChartContainer was to allow all MATLAB users to easily create their own charts that behave like first-class citizens, but it is worth mentioning for power users that:
  • update is a "special" method. The graphics system will automatically call update once per graphics update and it will only call update if something has indicated to the system that your chart is out-of-date (such as a property value was set or the figure changed sizes).
  • setup is much less "special". It is called automatically from the ChartContainer constructor. If your chart does not have the ConstructOnLoad attribute, it is also called once when your chart is loaded from a FIG-file. Otherwise, there is nothing stopping a power-user from leaving setup empty and putting all the setup code within their own custom constructor, in which case they would have access to anything the user specified.
  8 comentarios
Jan Kappen
Jan Kappen el 1 de Mzo. de 2025 a las 13:27
I continued playing around a bit and investigated other methods to mark the data dirty to avoid unnecessary re-plotting data attempts.
Of course your set method approach above works, but it's a bit of boilerplate, especially when there are more than just XData and YData or Data, but multiple public data properties (which happened to be in a confidence interval class I've recently written).
So, what about AbortSet? My current understanding is that it's never a bad idea for public properties that are expense to update to be used? Or are there side-conditions (other than the compare overhead for sure)?
Also, what about SetObservable? Does it interfer with the automatic update process you described above? I'm thinking of something like
% public properties with customized update behavior, because updates
% might be expensive
properties (SetObservable, AbortSet)
XData (:,1) {mustBeA(XData, ["duration", "datetime", "numeric"])}
YData (:,:) double
YReferenceData (:,:)
ConfidenceMode (1,1) string {mustBeMember(ConfidenceMode, ["property", "minmax", "std"])} = "minmax"
ConfidenceMargin (1,2)
CenterLineMode (1,1) string {mustBeMember(CenterLineMode, ["mean", "median"])} = "mean"
end
.
.
.
% add listeners to all props that are expensive to update
% just search for observable properties of this subclass, not
% the superclass(es)
mc = metaclass(obj);
metaprops = [mc.PropertyList(...
[mc.PropertyList.SetObservable] ...
& [mc.PropertyList.DefiningClass] == mc)];
addlistener(obj, metaprops, "PreSet", @obj.markDataDirty);
.
.
.
function markDataDirty(obj, src, evnt)
% potentially more checks
obj.NeedsDataUpdate = true;
end
This would potentially reduce the number of set methods a bit. Are there any downsides?
Thanks!
Benjamin Kraus
Benjamin Kraus el 3 de Mzo. de 2025 a las 18:29
@Jan Kappen: You are right, the set method approach definitely leads to a lot of boilerplate code (I feel that pain regularly). We have discussed internally a few features we could implement to reduce the amount of boilerplate code, but we haven't settled on a good solution yet.
AbortSet should work to avoid unneccesary updates. If the property has the AbortSet attribute and the new property value equals the old property value, the object won't be marked dirty and update won't run (unless something else has marked the object dirty). Note that "equals" is isequal in this case, which can mean some strange behaviors in some cases. For example, if the value of your property is double(1:10) and you set the property to int8(1:10), that is considered "equal" as far as AbortSet is concerned. It may also be useful to remember how isequal works with handle objects. This is the note from the isequal doc page on the topic: "When comparing two handle objects, use == to test whether objects have the same handle. Use isequal to determine if two objects with different handles have equal property values."
One other thing to note for AbortSet: The penalty isn't just the time it takes to compare the values, but it includes the time it takes to query the value, so if you have a get method for the property, keep in mind that the get method will run every time you set the property value, and sometimes that can be more expensive than the comparison.
As for SetObservable: I think your approach will work and I've used a pattern like that before, specifically when the property is defined in a base class (so I can't overload set). SetObservable won't impact the automatic update process and in your implementation NeedsDataUpdate will be set to true whenever the data does change. The SetObservable callback will execute before update runs. Your approach looks like a reasonable approach to avoid needing separate set methods for every property. I also think you can use PreSet or PostSet and both would work. I usually use PostSet, and I think that is what I would recommend unless you have a good reason for needing PreSet. The main reason is that if there are any unexpected side-effects of your PostSet callback, they will see the new property value instead of the old property value.
I think AbortSet is optional (and orthogonal) in your approach above, so if you didn't like the isequal behavior, your could drop AbortSet entirely. It would mean that NeedsDataUpdate would be set to true even if the data has not changed, but it would still allow you know that someone did set a value to that property (whether it was different or not).
As for AbortSet plus SetObservable: They work fine together, but note that AbortSet means the PreSet and PostSet events won't run unless the data changes.

Iniciar sesión para comentar.

Más respuestas (0)

Categorías

Más información sobre Developing Chart Classes en Help Center y File Exchange.

Productos

Community Treasure Hunt

Find the treasures in MATLAB Central and discover how the community can help you!

Start Hunting!

Translated by