Skip to content

Commit 38a9836

Browse files
authored
Dashboard API hardening: correctness + widget registry + nested-layout ergonomics (#178)
P0 correctness: active-page widget addressing; chart widgets read Tag data via getXY(); chart bindings restored on load; strict unknown-option guards on FastSense + widgets. P1 structure: DashboardWidgetRegistry as single source of truth (engine dispatch / serializer / detach routed through it); serialization schemaVersion stamped + checked. Nested-layout ergonomics: GroupWidget.removeChild (tabbed), GroupWidget.removeTab, DashboardEngine.removePage. Threshold consolidation: single isThresholdViolated predicate + MultiStatus inclusive-bound fix. Backward-compatible; tests + docs included.
1 parent 7664828 commit 38a9836

30 files changed

Lines changed: 1462 additions & 344 deletions

libs/Dashboard/BarChartWidget.m

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ function refresh(obj)
4242

4343
data = [];
4444
cats = {};
45-
if ~isempty(obj.Sensor)
46-
if isempty(obj.Sensor.Y), return; end
47-
data = obj.Sensor.Y;
45+
if ~isempty(obj.Tag)
46+
% Read via the Tag.getXY() contract so Derived/Composite tags
47+
% (which expose no public .Y) work, not just SensorTag/StateTag.
48+
[~, y] = obj.Tag.getXY();
49+
if isempty(y), return; end
50+
data = y;
4851
elseif ~isempty(obj.DataFcn)
4952
result = obj.DataFcn();
5053
if isstruct(result)
@@ -140,6 +143,18 @@ function refresh(obj)
140143
end
141144
if isfield(s, 'orientation'), obj.Orientation = s.orientation; end
142145
if isfield(s, 'stacked'), obj.Stacked = s.stacked; end
146+
% Restore the data binding (callback) — dropped before P0-3.
147+
% Old structs without s.source load with no binding and no warning.
148+
if isfield(s, 'source') && isfield(s.source, 'type')
149+
switch s.source.type
150+
case 'callback'
151+
obj.DataFcn = str2func(s.source.function);
152+
otherwise
153+
warning('BarChartWidget:sourceUnresolved', ...
154+
'Unresolved source type ''%s'' for BarChart ''%s''.', ...
155+
s.source.type, obj.Title);
156+
end
157+
end
143158
end
144159
end
145160
end

libs/Dashboard/ChipBarWidget.m

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,9 @@ function clearRenderLock_(obj)
303303
val = chip.value;
304304
end
305305
if isempty(val), chipColor = [0.5 0.5 0.5]; return; end
306-
tVals = t.allValues();
307306
state = 'ok';
308-
for v = 1:numel(tVals)
309-
if (t.IsUpper && val > tVals(v)) || (~t.IsUpper && val < tVals(v))
310-
state = 'alarm'; break;
311-
end
307+
if isThresholdViolated(t, val)
308+
state = 'alarm';
312309
end
313310
elseif isfield(chip, 'statusFcn') && ~isempty(chip.statusFcn)
314311
try
@@ -322,16 +319,10 @@ function clearRenderLock_(obj)
322319
latestY = sensor.Y(end);
323320
state = 'ok';
324321
for k = 1:numel(sensor.Thresholds)
325-
t = sensor.Thresholds{k};
326-
tVals = t.allValues();
327-
for v = 1:numel(tVals)
328-
if (t.IsUpper && latestY > tVals(v)) || ...
329-
(~t.IsUpper && latestY < tVals(v))
330-
state = 'alarm';
331-
break;
332-
end
322+
if isThresholdViolated(sensor.Thresholds{k}, latestY)
323+
state = 'alarm';
324+
break;
333325
end
334-
if strcmp(state, 'alarm'), break; end
335326
end
336327
else
337328
state = 'ok';

libs/Dashboard/DashboardEngine.m

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@
6363
% Theme caching
6464
ThemeCache_ = [] % Cached DashboardTheme struct; lazy-computed by getCachedTheme()
6565
ThemeCachePreset_ = '' % Theme preset string that ThemeCache_ was built for
66-
% Widget dispatch table
67-
WidgetTypeMap_ = [] % containers.Map: type string -> constructor function handle
6866
% Time control
6967
TimePanelHeight = 0.085 % bumped from 0.06 to fit data-range labels below slider (260512-hrn-followup)
7068
DataTimeRange = [0 1] % [tMin tMax] across all widget data
@@ -162,17 +160,8 @@
162160
end
163161
obj.Layout = DashboardLayout();
164162
obj.Layout.EngineRef = obj; % Phase 1032 PLOG-VIZ-05 — used by addPlantLogToggle callback
165-
obj.WidgetTypeMap_ = containers.Map({ ...
166-
'fastsense', 'number', 'status', 'text', ...
167-
'gauge', 'table', 'rawaxes', 'timeline', ...
168-
'group', 'heatmap', 'barchart', 'histogram', ...
169-
'scatter', 'image', 'multistatus', 'divider', ...
170-
'iconcard', 'chipbar', 'sparkline'}, ...
171-
{@FastSenseWidget, @NumberWidget, @StatusWidget, @TextWidget, ...
172-
@GaugeWidget, @TableWidget, @RawAxesWidget, @EventTimelineWidget, ...
173-
@GroupWidget, @HeatmapWidget, @BarChartWidget, @HistogramWidget, ...
174-
@ScatterWidget, @ImageWidget, @MultiStatusWidget, @DividerWidget, ...
175-
@IconCardWidget, @ChipBarWidget, @SparklineCardWidget});
163+
% Widget type->constructor dispatch now lives in DashboardWidgetRegistry
164+
% (the single source of truth shared with the serializer + detach paths).
176165
end
177166

178167
function pg = addPage(obj, name)
@@ -393,8 +382,8 @@ function switchPage(obj, pageIdx)
393382
type = 'number';
394383
end
395384

396-
if isKey(obj.WidgetTypeMap_, type)
397-
ctor = obj.WidgetTypeMap_(type);
385+
if DashboardWidgetRegistry.isRegistered(DashboardWidgetRegistry.resolveAlias(type))
386+
ctor = DashboardWidgetRegistry.constructorFor(type);
398387
w = ctor(varargin{:});
399388
else
400389
error('DashboardEngine:unknownType', ...
@@ -1188,7 +1177,8 @@ function preview(obj, varargin)
11881177
width = 48;
11891178
end
11901179

1191-
nWidgets = numel(obj.Widgets);
1180+
ws = obj.activePageWidgets();
1181+
nWidgets = numel(ws);
11921182

11931183
% Empty dashboard
11941184
if nWidgets == 0
@@ -1200,7 +1190,7 @@ function preview(obj, varargin)
12001190
cols = obj.Layout.Columns;
12011191
maxRow = 1;
12021192
for i = 1:nWidgets
1203-
p = obj.Widgets{i}.Position;
1193+
p = ws{i}.Position;
12041194
bottomRow = p(2) + p(4) - 1;
12051195
if bottomRow > maxRow
12061196
maxRow = bottomRow;
@@ -1218,7 +1208,7 @@ function preview(obj, varargin)
12181208

12191209
% Render each widget
12201210
for i = 1:nWidgets
1221-
w = obj.Widgets{i};
1211+
w = ws{i};
12221212
p = w.Position; % [col, row, wCols, hRows]
12231213

12241214
% Character coordinates (1-based)
@@ -1501,13 +1491,44 @@ function removeWidget(obj, idx)
15011491
end
15021492
end
15031493

1494+
function removePage(obj, idx)
1495+
%REMOVEPAGE Remove the page at index idx, keeping ActivePage valid.
1496+
% Mirror of removeWidget for pages. Throws DashboardEngine:invalidIndex
1497+
% on a bad index. Deletes the page's widgets and the page, adjusts
1498+
% ActivePage (decrements when removing a page before it; clamps when
1499+
% removing the active page; resets to 0 when no pages remain), and
1500+
% re-renders when a figure is live.
1501+
if idx < 1 || idx > numel(obj.Pages)
1502+
error('DashboardEngine:invalidIndex', ...
1503+
'Page index %d out of range [1, %d].', idx, numel(obj.Pages));
1504+
end
1505+
pg = obj.Pages{idx};
1506+
ws = pg.Widgets;
1507+
for i = 1:numel(ws)
1508+
delete(ws{i});
1509+
end
1510+
obj.Pages(idx) = [];
1511+
delete(pg);
1512+
if isempty(obj.Pages)
1513+
obj.ActivePage = 0;
1514+
elseif idx < obj.ActivePage
1515+
obj.ActivePage = obj.ActivePage - 1;
1516+
elseif idx == obj.ActivePage
1517+
obj.ActivePage = max(1, min(obj.ActivePage, numel(obj.Pages)));
1518+
end
1519+
if ~isempty(obj.hFigure) && ishandle(obj.hFigure)
1520+
obj.rerenderWidgets();
1521+
end
1522+
end
1523+
15041524
function setWidgetPosition(obj, idx, pos)
15051525
%SETWIDGETPOSITION Set the grid position of a widget by index.
15061526
% Clamps width to grid columns and resolves overlaps with other
1507-
% widgets.
1508-
if idx < 1 || idx > numel(obj.Widgets)
1527+
% widgets. Operates on the active page in multi-page mode.
1528+
ws = obj.activePageWidgets();
1529+
if idx < 1 || idx > numel(ws)
15091530
error('DashboardEngine:invalidIndex', ...
1510-
'Widget index %d out of range [1, %d].', idx, numel(obj.Widgets));
1531+
'Widget index %d out of range [1, %d].', idx, numel(ws));
15111532
end
15121533
% Clamp to grid bounds
15131534
cols = obj.Layout.Columns;
@@ -1516,25 +1537,28 @@ function setWidgetPosition(obj, idx, pos)
15161537
pos(2) = max(1, pos(2));
15171538
pos(4) = max(1, pos(4));
15181539
% Resolve overlaps against other widgets
1519-
existingPositions = cell(1, numel(obj.Widgets) - 1);
1540+
existingPositions = cell(1, numel(ws) - 1);
15201541
k = 0;
1521-
for i = 1:numel(obj.Widgets)
1542+
for i = 1:numel(ws)
15221543
if i ~= idx
15231544
k = k + 1;
1524-
existingPositions{k} = obj.Widgets{i}.Position;
1545+
existingPositions{k} = ws{i}.Position;
15251546
end
15261547
end
15271548
pos = obj.Layout.resolveOverlap(pos, existingPositions);
1528-
obj.Widgets{idx}.Position = pos;
1549+
% ws{idx} is a handle, so this mutates the widget stored in the page.
1550+
ws{idx}.Position = pos;
15291551
end
15301552

15311553
function w = getWidgetByTitle(obj, title)
15321554
%GETWIDGETBYTITLE Find a widget by its Title property.
1533-
% Returns the widget object, or empty if not found.
1555+
% Searches every page in multi-page mode (active page or single-page
1556+
% Widgets otherwise). Returns the widget object, or empty if not found.
15341557
w = [];
1535-
for i = 1:numel(obj.Widgets)
1536-
if strcmp(obj.Widgets{i}.Title, title)
1537-
w = obj.Widgets{i};
1558+
ws = obj.allPageWidgets();
1559+
for i = 1:numel(ws)
1560+
if strcmp(ws{i}.Title, title)
1561+
w = ws{i};
15381562
return;
15391563
end
15401564
end
@@ -2293,8 +2317,10 @@ function onLiveTick(obj)
22932317
function markAllDirty(obj)
22942318
%MARKALLDIRTY Flag all widgets as needing refresh.
22952319
% Called on theme change, figure resize, or other global state changes.
2296-
for i = 1:numel(obj.Widgets)
2297-
obj.Widgets{i}.markDirty();
2320+
% Covers every page in multi-page mode.
2321+
ws = obj.allPageWidgets();
2322+
for i = 1:numel(ws)
2323+
ws{i}.markDirty();
22982324
end
22992325
end
23002326

@@ -4473,25 +4499,57 @@ function onFigureDestroyed_(obj)
44734499

44744500
methods (Static)
44754501
function types = widgetTypes()
4476-
%WIDGETTYPES List supported widget type strings.
4477-
types = {
4478-
'fastsense', 'Time-series plot (FastSenseWidget)'
4479-
'number', 'Single numeric value with trend (NumberWidget)'
4480-
'status', 'Status indicator with dot and label (StatusWidget)'
4481-
'gauge', 'Gauge display in arc/donut/bar/thermometer style (GaugeWidget)'
4482-
'table', 'Data table from sensor (TableWidget)'
4483-
'text', 'Static text block (TextWidget)'
4484-
'timeline', 'Event timeline display (EventTimelineWidget)'
4485-
'rawaxes', 'Raw MATLAB axes for custom plotting (RawAxesWidget)'
4486-
'group', 'Widget container with panel/collapsible/tabbed modes (GroupWidget)'
4487-
'heatmap', 'Heatmap color grid (HeatmapWidget)'
4488-
'barchart', 'Bar chart for categories (BarChartWidget)'
4489-
'histogram', 'Value distribution histogram (HistogramWidget)'
4490-
'scatter', 'X vs Y scatter plot (ScatterWidget)'
4491-
'image', 'Static image display (ImageWidget)'
4492-
'multistatus', 'Multi-sensor status grid (MultiStatusWidget)'
4493-
'divider', 'Horizontal divider line (DividerWidget)'
4494-
};
4502+
%WIDGETTYPES Supported widget types + descriptions, as an Nx2 cell.
4503+
% Derived from DashboardWidgetRegistry.types() (the single source of
4504+
% truth), so it can no longer drift from what addWidget accepts, and
4505+
% user-registered types (registerWidgetType) appear automatically with
4506+
% a generic description.
4507+
descMap = containers.Map( ...
4508+
{'fastsense', 'number', 'status', 'gauge', 'table', 'text', ...
4509+
'timeline', 'rawaxes', 'group', 'heatmap', 'barchart', ...
4510+
'histogram', 'scatter', 'image', 'multistatus', 'divider', ...
4511+
'iconcard', 'chipbar', 'sparkline'}, ...
4512+
{'Time-series plot (FastSenseWidget)', ...
4513+
'Single numeric value with trend (NumberWidget)', ...
4514+
'Status indicator with dot and label (StatusWidget)', ...
4515+
'Gauge display in arc/donut/bar/thermometer style (GaugeWidget)', ...
4516+
'Data table from sensor (TableWidget)', ...
4517+
'Static text block (TextWidget)', ...
4518+
'Event timeline display (EventTimelineWidget)', ...
4519+
'Raw MATLAB axes for custom plotting (RawAxesWidget)', ...
4520+
'Widget container with panel/collapsible/tabbed modes (GroupWidget)', ...
4521+
'Heatmap color grid (HeatmapWidget)', ...
4522+
'Bar chart for categories (BarChartWidget)', ...
4523+
'Value distribution histogram (HistogramWidget)', ...
4524+
'X vs Y scatter plot (ScatterWidget)', ...
4525+
'Static image display (ImageWidget)', ...
4526+
'Multi-sensor status grid (MultiStatusWidget)', ...
4527+
'Horizontal divider line (DividerWidget)', ...
4528+
'Icon + value card (IconCardWidget)', ...
4529+
'Chip/badge status bar (ChipBarWidget)', ...
4530+
'Sparkline value card (SparklineCardWidget)'});
4531+
names = DashboardWidgetRegistry.types();
4532+
types = cell(numel(names), 2);
4533+
for i = 1:numel(names)
4534+
types{i, 1} = names{i};
4535+
if descMap.isKey(names{i})
4536+
types{i, 2} = descMap(names{i});
4537+
else
4538+
types{i, 2} = sprintf('%s widget', names{i});
4539+
end
4540+
end
4541+
end
4542+
4543+
function registerWidgetType(type, ctorHandle)
4544+
%REGISTERWIDGETTYPE Register a custom widget type with the dashboard.
4545+
% DashboardEngine.registerWidgetType('mytype', @MyWidget) makes
4546+
% 'mytype' usable through addWidget, serialization, and detach — the
4547+
% documented extension point for third-party widgets. The widget class
4548+
% must subclass DashboardWidget and provide a static fromStruct.
4549+
% Errors DashboardWidgetRegistry:duplicateType on a name collision.
4550+
%
4551+
% See also DashboardWidgetRegistry.register, DashboardEngine.widgetTypes.
4552+
DashboardWidgetRegistry.register(type, ctorHandle);
44954553
end
44964554

44974555
function obj = load(filepath, varargin)

0 commit comments

Comments
 (0)