From 467bb8a4a70a3256f1a8218a45412acfb59ffb55 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:31:45 +0100 Subject: [PATCH 01/12] docs: add ExternalSensorRegistry design spec Defines a standalone registry class for integrating external .mat data into the FastSense pipeline without modifying existing APIs. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...6-03-18-external-sensor-registry-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md diff --git a/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md new file mode 100644 index 00000000..814f80bd --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md @@ -0,0 +1,154 @@ +# External Sensor Registry Design + +**Date:** 2026-03-18 +**Status:** Draft + +## Problem + +External libraries produce .mat files containing raw timeseries data (multiple signals per file, multiple files). We need a way to define sensors against this data and integrate them into the FastSense live pipeline — without modifying the existing `SensorRegistry` or any other FastPlot API. + +## Solution + +A single new class: `ExternalSensorRegistry`, located in `libs/SensorThreshold/ExternalSensorRegistry.m`. + +## Design + +### ExternalSensorRegistry + +A non-singleton registry where sensors are explicitly defined in code and wired to .mat file data sources. + +**Key differences from SensorRegistry:** +- Not a singleton — multiple instances allowed (one per external library/project) +- No hardcoded `catalog()` — sensors are registered externally via `register()` +- Owns a `DataSourceMap` built up by `wireMatFile()` calls +- Has a `Name` property to identify the registry instance + +**Properties:** +- `Name` (char) — human-readable label (e.g., `'VibrationLab'`) +- `Catalog` (containers.Map) — char → Sensor mapping +- `DSMap` (DataSourceMap) — internal, built by `wireMatFile()` + +### Public API + +#### Sensor Management (mirrors SensorRegistry) + +| Method | Signature | Description | +|--------|-----------|-------------| +| Constructor | `ExternalSensorRegistry(name)` | Create registry with a name | +| `register` | `register(key, sensor)` | Add a Sensor to the catalog | +| `unregister` | `unregister(key)` | Remove a Sensor from the catalog | +| `get` | `get(key)` → Sensor | Retrieve sensor by key | +| `getMultiple` | `getMultiple(keys)` → cell array | Retrieve multiple sensors | +| `getAll` | `getAll()` → containers.Map | Return full catalog | +| `keys` | `keys()` → cell array | All registered keys | +| `count` | `count()` → double | Number of sensors | +| `list` | `list()` | Print summary table to console | +| `printTable` | `printTable()` | Print detailed table | +| `viewer` | `viewer()` → figure | GUI uitable of all sensors | + +#### Data Wiring + +| Method | Signature | Description | +|--------|-----------|-------------| +| `wireMatFile` | `wireMatFile(path, mappings)` | Wire .mat file fields to sensor keys | +| `wireStateChannel` | `wireStateChannel(sensorKey, stateKey, matPath, NV...)` | Wire state channel data to a sensor | +| `getDataSourceMap` | `getDataSourceMap()` → DataSourceMap | Return DataSourceMap for pipeline use | + +### wireMatFile + +Connects fields in a .mat file to sensors already registered in the catalog. + +**Signature:** +```matlab +reg.wireMatFile(matFilePath, mappings) +``` + +**Parameters:** +- `matFilePath` (char) — path to the .mat file +- `mappings` (Nx3+ cell array) — each row: `{sensorKey, 'XVar', xFieldName, 'YVar', yFieldName}` + +**Behavior:** +1. For each row in mappings: + - Validates that `sensorKey` exists in the catalog (error if not) + - Sets `Sensor.MatFile = matFilePath` + - Sets `Sensor.KeyName` to the YVar field name + - Creates a `MatFileDataSource(matFilePath, 'XVar', xField, 'YVar', yField)` + - Adds it to the internal `DataSourceMap` under the sensor key +2. If a sensor key is already wired, the new wiring overwrites with a warning + +### wireStateChannel + +Attaches a state channel from a .mat file to a registered sensor. + +**Signature:** +```matlab +reg.wireStateChannel(sensorKey, stateKey, matFilePath, 'XVar', xField, 'YVar', yField) +``` + +**Behavior:** +1. Validates `sensorKey` exists in the catalog (error if not) +2. Creates a `StateChannel('Key', stateKey, 'MatFile', matFilePath, 'KeyName', yField)` +3. Calls `sensor.addStateChannel(sc)` on the target sensor +4. Updates the `MatFileDataSource` for the sensor to include `StateXVar`/`StateYVar` if present + +### getDataSourceMap + +Returns the internal `DataSourceMap` so it can be passed directly to `LiveEventPipeline`. + +### Usage Example + +```matlab +%% 1. Define the registry +reg = ExternalSensorRegistry('VibrationLab'); + +%% 2. Define sensors explicitly +s1 = Sensor('Key', 'bearing_temp', 'Name', 'Bearing Temperature', ... + 'Units', 'degC', 'ID', 101); +s1.addThresholdRule(struct(), 85, 'Direction', 'upper', 'Label', 'Warning'); +s1.addThresholdRule(struct(), 95, 'Direction', 'upper', 'Label', 'Critical'); +reg.register('bearing_temp', s1); + +s2 = Sensor('Key', 'oil_pressure', 'Name', 'Oil Pressure', ... + 'Units', 'bar', 'ID', 102); +s2.addThresholdRule(struct(), 2.0, 'Direction', 'lower', 'Label', 'Low Pressure'); +reg.register('oil_pressure', s2); + +%% 3. Wire .mat file data +reg.wireMatFile('lab1/vibration.mat', { + 'bearing_temp', 'XVar', 'time', 'YVar', 'temp_bearing'; + 'oil_pressure', 'XVar', 'time', 'YVar', 'press_oil'; +}); + +%% 4. Wire state channels +reg.wireStateChannel('bearing_temp', 'machine_state', ... + 'lab1/states.mat', 'XVar', 'state_time', 'YVar', 'state_val'); + +%% 5. Use with live pipeline +dsMap = reg.getDataSourceMap(); +sensors = reg.getAll(); + +pipeline = LiveEventPipeline(sensors, dsMap, ... + 'EventStorePath', 'output/events.mat', ... + 'Interval', 15); +pipeline.start(); +``` + +## Scope + +### In scope +- `ExternalSensorRegistry` class with full API +- `wireMatFile` and `wireStateChannel` methods +- Integration with existing `LiveEventPipeline` via `getDataSourceMap()` + +### Out of scope +- Changes to `SensorRegistry`, `Sensor`, `DataSource`, `LiveEventPipeline`, or any other existing class +- Config-file-based sensor definitions (all config is in code) +- Auto-discovery of signals from .mat files + +## File Changes + +| File | Change | +|------|--------| +| `libs/SensorThreshold/ExternalSensorRegistry.m` | **New** — the entire design | + +One new file. Zero modifications to existing files. From 14bec2f40810b5b380a6cabfccfa6e44a59c9976 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:33:22 +0100 Subject: [PATCH 02/12] docs: fix spec issues from review - Fix Sensor/StateChannel constructor calls (key is positional) - Clarify cross-file state channel wiring (StateChannel loads its own data) - Fix LiveEventPipeline parameter name (EventFile, not EventStorePath) - Document struct() as unconditional threshold convention - Clarify duplicate key warning behavior - Fix "mirrors SensorRegistry" claim for new methods Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-03-18-external-sensor-registry-design.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md index 814f80bd..cdc5c970 100644 --- a/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md +++ b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md @@ -30,7 +30,7 @@ A non-singleton registry where sensors are explicitly defined in code and wired ### Public API -#### Sensor Management (mirrors SensorRegistry) +#### Sensor Management (based on SensorRegistry, with additions) | Method | Signature | Description | |--------|-----------|-------------| @@ -74,7 +74,7 @@ reg.wireMatFile(matFilePath, mappings) - Sets `Sensor.KeyName` to the YVar field name - Creates a `MatFileDataSource(matFilePath, 'XVar', xField, 'YVar', yField)` - Adds it to the internal `DataSourceMap` under the sensor key -2. If a sensor key is already wired, the new wiring overwrites with a warning +2. If a sensor key is already wired (checked via `DataSourceMap.has()`), the new wiring overwrites with a warning issued via `warning()` ### wireStateChannel @@ -87,9 +87,10 @@ reg.wireStateChannel(sensorKey, stateKey, matFilePath, 'XVar', xField, 'YVar', y **Behavior:** 1. Validates `sensorKey` exists in the catalog (error if not) -2. Creates a `StateChannel('Key', stateKey, 'MatFile', matFilePath, 'KeyName', yField)` +2. Creates a `StateChannel(stateKey, 'MatFile', matFilePath, 'KeyName', yField)` (note: `stateKey` is positional) 3. Calls `sensor.addStateChannel(sc)` on the target sensor -4. Updates the `MatFileDataSource` for the sensor to include `StateXVar`/`StateYVar` if present +4. If the state data lives in the **same** .mat file as the sensor data, updates the existing `MatFileDataSource` to include `StateXVar`/`StateYVar` +5. If the state data lives in a **different** .mat file, the `StateChannel` loads its own data via `StateChannel.MatFile` / `StateChannel.load()` — no changes to the sensor's `MatFileDataSource` ### getDataSourceMap @@ -101,14 +102,15 @@ Returns the internal `DataSourceMap` so it can be passed directly to `LiveEventP %% 1. Define the registry reg = ExternalSensorRegistry('VibrationLab'); -%% 2. Define sensors explicitly -s1 = Sensor('Key', 'bearing_temp', 'Name', 'Bearing Temperature', ... +%% 2. Define sensors explicitly (note: key is positional first argument) +s1 = Sensor('bearing_temp', 'Name', 'Bearing Temperature', ... 'Units', 'degC', 'ID', 101); +% struct() = empty condition = unconditional (always active regardless of state) s1.addThresholdRule(struct(), 85, 'Direction', 'upper', 'Label', 'Warning'); s1.addThresholdRule(struct(), 95, 'Direction', 'upper', 'Label', 'Critical'); reg.register('bearing_temp', s1); -s2 = Sensor('Key', 'oil_pressure', 'Name', 'Oil Pressure', ... +s2 = Sensor('oil_pressure', 'Name', 'Oil Pressure', ... 'Units', 'bar', 'ID', 102); s2.addThresholdRule(struct(), 2.0, 'Direction', 'lower', 'Label', 'Low Pressure'); reg.register('oil_pressure', s2); @@ -128,7 +130,7 @@ dsMap = reg.getDataSourceMap(); sensors = reg.getAll(); pipeline = LiveEventPipeline(sensors, dsMap, ... - 'EventStorePath', 'output/events.mat', ... + 'EventFile', 'output/events.mat', ... 'Interval', 15); pipeline.start(); ``` From 54817f601b72f2346da91dcb4ecdc16b34f42ff7 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:34:57 +0100 Subject: [PATCH 03/12] docs: fix remaining spec review issues - Clarify which methods are new vs mirrored from SensorRegistry - Make wireStateChannel signature explicit (no NV... ambiguity) - Specify handle mutation for same-file state wiring Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-18-external-sensor-registry-design.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md index cdc5c970..e6adbf16 100644 --- a/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md +++ b/docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md @@ -30,7 +30,9 @@ A non-singleton registry where sensors are explicitly defined in code and wired ### Public API -#### Sensor Management (based on SensorRegistry, with additions) +#### Sensor Management + +Methods `get`, `getMultiple`, `register`, `unregister`, `list`, `printTable`, and `viewer` follow the same signatures as `SensorRegistry`. Methods `getAll`, `keys`, and `count` are new (no counterpart in `SensorRegistry`). | Method | Signature | Description | |--------|-----------|-------------| @@ -51,7 +53,7 @@ A non-singleton registry where sensors are explicitly defined in code and wired | Method | Signature | Description | |--------|-----------|-------------| | `wireMatFile` | `wireMatFile(path, mappings)` | Wire .mat file fields to sensor keys | -| `wireStateChannel` | `wireStateChannel(sensorKey, stateKey, matPath, NV...)` | Wire state channel data to a sensor | +| `wireStateChannel` | `wireStateChannel(sensorKey, stateKey, matFilePath, 'XVar', xField, 'YVar', yField)` | Wire state channel data to a sensor | | `getDataSourceMap` | `getDataSourceMap()` → DataSourceMap | Return DataSourceMap for pipeline use | ### wireMatFile @@ -89,7 +91,7 @@ reg.wireStateChannel(sensorKey, stateKey, matFilePath, 'XVar', xField, 'YVar', y 1. Validates `sensorKey` exists in the catalog (error if not) 2. Creates a `StateChannel(stateKey, 'MatFile', matFilePath, 'KeyName', yField)` (note: `stateKey` is positional) 3. Calls `sensor.addStateChannel(sc)` on the target sensor -4. If the state data lives in the **same** .mat file as the sensor data, updates the existing `MatFileDataSource` to include `StateXVar`/`StateYVar` +4. If the state data lives in the **same** .mat file as the sensor data, sets `ds.StateXVar` and `ds.StateYVar` directly on the existing `MatFileDataSource` handle (retrieved via `DSMap.get(sensorKey)`). Do not replace the data source object, as that would reset its internal polling index. 5. If the state data lives in a **different** .mat file, the `StateChannel` loads its own data via `StateChannel.MatFile` / `StateChannel.load()` — no changes to the sensor's `MatFileDataSource` ### getDataSourceMap From 96c860ff1d692cd506d19a6e371470d23e0cc810 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:40:56 +0100 Subject: [PATCH 04/12] docs: add ExternalSensorRegistry implementation plan - 7 tasks across 3 chunks (core registry, data wiring, viewer+integration) - TDD approach with 24 tests covering happy path and error cases - Fixes from review: count() returns double, getAll() returns copy, added tests for non-Sensor register, nonexistent unregister Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-18-external-sensor-registry.md | 824 ++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-18-external-sensor-registry.md diff --git a/docs/superpowers/plans/2026-03-18-external-sensor-registry.md b/docs/superpowers/plans/2026-03-18-external-sensor-registry.md new file mode 100644 index 00000000..1bfc1ef3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-external-sensor-registry.md @@ -0,0 +1,824 @@ +# ExternalSensorRegistry Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `ExternalSensorRegistry` class that lets users explicitly define sensors and wire them to external .mat file data sources, without modifying any existing FastPlot code. + +**Architecture:** Single new class `ExternalSensorRegistry` in `libs/SensorThreshold/`. It holds a `containers.Map` of Sensor objects and an internal `DataSourceMap`. Sensors are registered explicitly; data wiring is a separate step via `wireMatFile` and `wireStateChannel`. The resulting `DataSourceMap` plugs directly into the existing `LiveEventPipeline`. + +**Tech Stack:** MATLAB, matlab.unittest framework + +**Spec:** `docs/superpowers/specs/2026-03-18-external-sensor-registry-design.md` + +--- + +## Chunk 1: Core Registry + +### Task 1: Test — Constructor and Name property + +**Files:** +- Create: `tests/suite/TestExternalSensorRegistry.m` + +- [ ] **Step 1: Write the failing test** + +```matlab +classdef TestExternalSensorRegistry < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testConstructor(testCase) + reg = ExternalSensorRegistry('TestLab'); + testCase.verifyEqual(reg.Name, 'TestLab', 'name_set'); + end + + function testEmptyOnCreation(testCase) + reg = ExternalSensorRegistry('TestLab'); + testCase.verifyEqual(reg.count(), 0, 'empty_count'); + testCase.verifyTrue(isempty(reg.keys()), 'empty_keys'); + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `ExternalSensorRegistry` class not found + +- [ ] **Step 3: Write minimal implementation — constructor, Name, count, keys** + +Create `libs/SensorThreshold/ExternalSensorRegistry.m`: + +```matlab +classdef ExternalSensorRegistry < handle + %EXTERNALSENSORREGISTRY Non-singleton sensor registry for external data. + % ExternalSensorRegistry holds explicitly registered Sensor objects + % and wires them to .mat file data sources for use with + % LiveEventPipeline. + % + % Unlike SensorRegistry (singleton with hardcoded catalog), this + % class supports multiple instances and is populated via register(). + % + % See also SensorRegistry, Sensor, DataSourceMap. + + properties + Name % char: human-readable label for this registry + end + + properties (Access = private) + catalog_ % containers.Map (char -> Sensor) + dsMap_ % DataSourceMap + end + + methods + function obj = ExternalSensorRegistry(name) + %EXTERNALSENSORREGISTRY Construct a named registry. + % reg = ExternalSensorRegistry('MyLab') + obj.Name = name; + obj.catalog_ = containers.Map('KeyType', 'char', 'ValueType', 'any'); + obj.dsMap_ = DataSourceMap(); + end + + function n = count(obj) + %COUNT Number of registered sensors. + n = double(obj.catalog_.Count); + end + + function k = keys(obj) + %KEYS Return all registered sensor keys. + k = obj.catalog_.keys(); + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add ExternalSensorRegistry constructor with count/keys" +``` + +--- + +### Task 2: Test and implement — register, get, unregister + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` +- Modify: `libs/SensorThreshold/ExternalSensorRegistry.m` + +- [ ] **Step 1: Add failing tests** + +Add to the `methods (Test)` block in `TestExternalSensorRegistry.m`: + +```matlab +function testRegisterAndGet(testCase) + reg = ExternalSensorRegistry('TestLab'); + s = Sensor('temp', 'Name', 'Temperature'); + reg.register('temp', s); + out = reg.get('temp'); + testCase.verifyEqual(out.Key, 'temp', 'get_key'); + testCase.verifyEqual(out.Name, 'Temperature', 'get_name'); + testCase.verifyEqual(reg.count(), 1, 'count_after_register'); +end + +function testGetUnknownKeyThrows(testCase) + reg = ExternalSensorRegistry('TestLab'); + threw = false; + try + reg.get('nonexistent'); + catch + threw = true; + end + testCase.verifyTrue(threw, 'should_throw'); +end + +function testUnregister(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('temp', Sensor('temp')); + reg.unregister('temp'); + testCase.verifyEqual(reg.count(), 0, 'empty_after_unregister'); +end + +function testGetMultiple(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('a', Sensor('a')); + reg.register('b', Sensor('b')); + out = reg.getMultiple({'a', 'b'}); + testCase.verifyEqual(numel(out), 2, 'getMultiple_count'); + testCase.verifyEqual(out{1}.Key, 'a', 'getMultiple_key1'); + testCase.verifyEqual(out{2}.Key, 'b', 'getMultiple_key2'); +end + +function testGetAll(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('a', Sensor('a')); + reg.register('b', Sensor('b')); + m = reg.getAll(); + testCase.verifyTrue(isa(m, 'containers.Map'), 'getAll_type'); + testCase.verifyEqual(m.Count, uint64(2), 'getAll_count'); +end + +function testGetAllReturnsCopy(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('a', Sensor('a')); + m = reg.getAll(); + m('injected') = Sensor('injected'); + % Original registry should be unaffected + testCase.verifyEqual(reg.count(), 1, 'copy_not_mutated'); +end + +function testRegisterNonSensorThrows(testCase) + reg = ExternalSensorRegistry('TestLab'); + threw = false; + try + reg.register('bad', struct('Key', 'bad')); + catch + threw = true; + end + testCase.verifyTrue(threw, 'should_throw_non_sensor'); +end + +function testUnregisterNonexistentNoError(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.unregister('nonexistent'); % should not error +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `register`, `get`, etc. not defined + +- [ ] **Step 3: Implement register, get, unregister, getMultiple, getAll** + +Add to the `methods` block of `ExternalSensorRegistry.m`: + +```matlab +function register(obj, key, sensor) + %REGISTER Add a Sensor to the catalog. + % reg.register('key', sensorObj) + assert(isa(sensor, 'Sensor'), ... + 'ExternalSensorRegistry:invalidType', ... + 'Value must be a Sensor object.'); + obj.catalog_(key) = sensor; +end + +function unregister(obj, key) + %UNREGISTER Remove a Sensor from the catalog. + if obj.catalog_.isKey(key) + obj.catalog_.remove(key); + end +end + +function s = get(obj, key) + %GET Retrieve a sensor by key. + if ~obj.catalog_.isKey(key) + error('ExternalSensorRegistry:unknownKey', ... + 'No sensor with key ''%s'' in registry ''%s''.', key, obj.Name); + end + s = obj.catalog_(key); +end + +function sensors = getMultiple(obj, keys) + %GETMULTIPLE Retrieve multiple sensors by key. + sensors = cell(1, numel(keys)); + for i = 1:numel(keys) + sensors{i} = obj.get(keys{i}); + end +end + +function m = getAll(obj) + %GETALL Return a copy of the catalog as a containers.Map. + m = containers.Map(obj.catalog_.keys(), obj.catalog_.values()); +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add register/get/unregister/getMultiple/getAll to ExternalSensorRegistry" +``` + +--- + +### Task 3: Test and implement — list and printTable + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` +- Modify: `libs/SensorThreshold/ExternalSensorRegistry.m` + +- [ ] **Step 1: Add failing tests** + +Add to `methods (Test)`: + +```matlab +function testListNoError(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('temp', Sensor('temp', 'Name', 'Temperature')); + reg.list(); % should not error +end + +function testListEmpty(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.list(); % should not error on empty registry +end + +function testPrintTableNoError(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('temp', Sensor('temp', 'Name', 'Temperature', 'ID', 1)); + reg.printTable(); % should not error +end + +function testPrintTableEmpty(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.printTable(); % should not error on empty registry +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `list` and `printTable` not defined + +- [ ] **Step 3: Implement list and printTable** + +Add to the `methods` block of `ExternalSensorRegistry.m`. Follow the same pattern as `SensorRegistry.list()` and `SensorRegistry.printTable()` (see `libs/SensorThreshold/SensorRegistry.m:78-156`), but operate on `obj.catalog_` instead of the static `catalog()`: + +```matlab +function list(obj) + %LIST Print all registered sensor keys and names. + ks = sort(obj.catalog_.keys()); + fprintf('\n [%s] Available sensors:\n', obj.Name); + for i = 1:numel(ks) + s = obj.catalog_(ks{i}); + name = s.Name; + if isempty(name); name = '(no name)'; end + fprintf(' %-25s %s\n', ks{i}, name); + end + fprintf('\n'); +end + +function printTable(obj) + %PRINTTABLE Print a detailed table of all registered sensors. + ks = sort(obj.catalog_.keys()); + nSensors = numel(ks); + if nSensors == 0 + fprintf('No sensors registered in ''%s''.\n', obj.Name); + return; + end + fprintf('\n [%s]\n', obj.Name); + fprintf(' %-20s %-25s %6s %-20s %-20s %7s %6s %8s\n', ... + 'Key', 'Name', 'ID', 'Source', 'MatFile', '#States', '#Rules', '#Points'); + fprintf(' %s\n', repmat('-', 1, 118)); + for i = 1:nSensors + s = obj.catalog_(ks{i}); + name = s.Name; if isempty(name); name = ''; end + idStr = ''; if ~isempty(s.ID); idStr = num2str(s.ID); end + nStates = numel(s.StateChannels); + nRules = numel(s.ThresholdRules); + nPts = numel(s.X); + fprintf(' %-20s %-25s %6s %-20s %-20s %7d %6d %8d\n', ... + ExternalSensorRegistry.truncStr(ks{i}, 20), ... + ExternalSensorRegistry.truncStr(name, 25), ... + idStr, ... + ExternalSensorRegistry.truncStr(s.Source, 20), ... + ExternalSensorRegistry.truncStr(s.MatFile, 20), ... + nStates, nRules, nPts); + end + fprintf('\n %d sensor(s) total.\n\n', nSensors); +end +``` + +Also add a private static helper (add a new `methods (Static, Access = private)` block): + +```matlab +methods (Static, Access = private) + function s = truncStr(s, maxLen) + if isempty(s); s = ''; end + if numel(s) > maxLen + s = [s(1:maxLen-2), '..']; + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (14 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add list/printTable to ExternalSensorRegistry" +``` + +--- + +## Chunk 2: Data Wiring + +### Task 4: Test and implement — wireMatFile + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` +- Modify: `libs/SensorThreshold/ExternalSensorRegistry.m` + +- [ ] **Step 1: Create a test .mat fixture** + +Add a `TestMethodSetup` block that creates a temporary .mat file: + +```matlab +properties + TempDir +end + +methods (TestMethodSetup) + function createTempDir(testCase) + testCase.TempDir = tempname(); + mkdir(testCase.TempDir); + testCase.addTeardown(@() rmdir(testCase.TempDir, 's')); + end +end +``` + +- [ ] **Step 2: Add failing tests for wireMatFile** + +Add to `methods (Test)`: + +```matlab +function testWireMatFile(testCase) + % Create a .mat file with two signals + time = [1 2 3 4 5]; + temp_bearing = [20 21 22 23 24]; + press_oil = [5 5.1 5.2 5.3 5.4]; + matPath = fullfile(testCase.TempDir, 'data.mat'); + save(matPath, 'time', 'temp_bearing', 'press_oil'); + + reg = ExternalSensorRegistry('TestLab'); + reg.register('bearing_temp', Sensor('bearing_temp')); + reg.register('oil_pressure', Sensor('oil_pressure')); + + reg.wireMatFile(matPath, { + 'bearing_temp', 'XVar', 'time', 'YVar', 'temp_bearing'; + 'oil_pressure', 'XVar', 'time', 'YVar', 'press_oil'; + }); + + % Verify Sensor properties were set + s1 = reg.get('bearing_temp'); + testCase.verifyEqual(s1.MatFile, matPath, 'matfile_set'); + + % Verify DataSourceMap was populated + dsMap = reg.getDataSourceMap(); + testCase.verifyTrue(dsMap.has('bearing_temp'), 'ds_bearing'); + testCase.verifyTrue(dsMap.has('oil_pressure'), 'ds_oil'); +end + +function testWireMatFileUnknownKeyThrows(testCase) + matPath = fullfile(testCase.TempDir, 'empty.mat'); + x = 1; save(matPath, 'x'); + + reg = ExternalSensorRegistry('TestLab'); + threw = false; + try + reg.wireMatFile(matPath, {'nonexistent', 'XVar', 'x', 'YVar', 'x'}); + catch + threw = true; + end + testCase.verifyTrue(threw, 'should_throw_unknown_key'); +end + +function testWireMatFileDuplicateWarns(testCase) + time = [1 2 3]; val = [10 20 30]; + matPath = fullfile(testCase.TempDir, 'data.mat'); + save(matPath, 'time', 'val'); + + reg = ExternalSensorRegistry('TestLab'); + reg.register('s1', Sensor('s1')); + reg.wireMatFile(matPath, {'s1', 'XVar', 'time', 'YVar', 'val'}); + + % Wire again — should warn but not error + reg.wireMatFile(matPath, {'s1', 'XVar', 'time', 'YVar', 'val'}); + + % Should still work + dsMap = reg.getDataSourceMap(); + testCase.verifyTrue(dsMap.has('s1'), 'still_wired'); +end + +function testGetDataSourceMap(testCase) + reg = ExternalSensorRegistry('TestLab'); + dsMap = reg.getDataSourceMap(); + testCase.verifyTrue(isa(dsMap, 'DataSourceMap'), 'returns_dsmap'); +end +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `wireMatFile` and `getDataSourceMap` not defined + +- [ ] **Step 4: Implement wireMatFile and getDataSourceMap** + +Add to the `methods` block of `ExternalSensorRegistry.m`: + +```matlab +function wireMatFile(obj, matFilePath, mappings) + %WIREMATFILE Wire .mat file fields to registered sensor keys. + % reg.wireMatFile('data.mat', { + % 'sensorKey', 'XVar', 'time', 'YVar', 'value'; + % }) + % + % Each row of mappings: {sensorKey, 'XVar', xField, 'YVar', yField} + for i = 1:size(mappings, 1) + key = mappings{i, 1}; + if ~obj.catalog_.isKey(key) + error('ExternalSensorRegistry:unknownKey', ... + 'Cannot wire ''%s'': not registered in ''%s''.', key, obj.Name); + end + + % Parse name-value pairs from remaining columns + nvPairs = mappings(i, 2:end); + p = inputParser(); + p.addParameter('XVar', 'X', @ischar); + p.addParameter('YVar', 'Y', @ischar); + p.parse(nvPairs{:}); + + % Set Sensor properties + s = obj.catalog_(key); + s.MatFile = matFilePath; + s.KeyName = p.Results.YVar; + + % Create MatFileDataSource + ds = MatFileDataSource(matFilePath, ... + 'XVar', p.Results.XVar, 'YVar', p.Results.YVar); + + % Warn on overwrite + if obj.dsMap_.has(key) + warning('ExternalSensorRegistry:overwrite', ... + 'Overwriting data source for ''%s'' in ''%s''.', key, obj.Name); + end + obj.dsMap_.add(key, ds); + end +end + +function dsMap = getDataSourceMap(obj) + %GETDATASOURCEMAP Return the DataSourceMap for pipeline use. + dsMap = obj.dsMap_; +end +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (18 tests) + +- [ ] **Step 6: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add wireMatFile and getDataSourceMap to ExternalSensorRegistry" +``` + +--- + +### Task 5: Test and implement — wireStateChannel + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` +- Modify: `libs/SensorThreshold/ExternalSensorRegistry.m` + +- [ ] **Step 1: Add failing tests for wireStateChannel** + +Add to `methods (Test)`: + +```matlab +function testWireStateChannelSameFile(testCase) + % State data in same file as sensor data + time = [1 2 3 4 5]; + val = [10 20 30 40 50]; + state_time = [1 3]; + state_val = {{'idle', 'running'}}; + matPath = fullfile(testCase.TempDir, 'combined.mat'); + save(matPath, 'time', 'val', 'state_time', 'state_val'); + + reg = ExternalSensorRegistry('TestLab'); + reg.register('s1', Sensor('s1')); + reg.wireMatFile(matPath, {'s1', 'XVar', 'time', 'YVar', 'val'}); + reg.wireStateChannel('s1', 'machine_state', matPath, ... + 'XVar', 'state_time', 'YVar', 'state_val'); + + s = reg.get('s1'); + testCase.verifyEqual(numel(s.StateChannels), 1, 'one_state_channel'); + testCase.verifyEqual(s.StateChannels{1}.Key, 'machine_state', 'sc_key'); + + % For same-file case, DataSource should have StateXVar/StateYVar set + ds = reg.getDataSourceMap().get('s1'); + testCase.verifyEqual(ds.StateXVar, 'state_time', 'ds_stateXVar'); + testCase.verifyEqual(ds.StateYVar, 'state_val', 'ds_stateYVar'); +end + +function testWireStateChannelDifferentFile(testCase) + % Sensor data in one file, state data in another + time = [1 2 3 4 5]; val = [10 20 30 40 50]; + sensorPath = fullfile(testCase.TempDir, 'sensor.mat'); + save(sensorPath, 'time', 'val'); + + state_time = [1 3]; state_val = {{'idle', 'running'}}; + statePath = fullfile(testCase.TempDir, 'states.mat'); + save(statePath, 'state_time', 'state_val'); + + reg = ExternalSensorRegistry('TestLab'); + reg.register('s1', Sensor('s1')); + reg.wireMatFile(sensorPath, {'s1', 'XVar', 'time', 'YVar', 'val'}); + reg.wireStateChannel('s1', 'machine_state', statePath, ... + 'XVar', 'state_time', 'YVar', 'state_val'); + + s = reg.get('s1'); + testCase.verifyEqual(numel(s.StateChannels), 1, 'one_state_channel'); + sc = s.StateChannels{1}; + testCase.verifyEqual(sc.MatFile, statePath, 'sc_matfile'); + testCase.verifyEqual(sc.KeyName, 'state_val', 'sc_keyname'); + + % DataSource should NOT have StateXVar set (different file) + ds = reg.getDataSourceMap().get('s1'); + testCase.verifyEqual(ds.StateXVar, '', 'ds_no_stateXVar'); +end + +function testWireStateChannelUnknownSensorThrows(testCase) + reg = ExternalSensorRegistry('TestLab'); + threw = false; + try + reg.wireStateChannel('nonexistent', 'state', 'file.mat', ... + 'XVar', 'x', 'YVar', 'y'); + catch + threw = true; + end + testCase.verifyTrue(threw, 'should_throw'); +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `wireStateChannel` not defined + +- [ ] **Step 3: Implement wireStateChannel** + +Add to the `methods` block of `ExternalSensorRegistry.m`: + +```matlab +function wireStateChannel(obj, sensorKey, stateKey, matFilePath, varargin) + %WIRESTATECHANNEL Wire state channel data to a registered sensor. + % reg.wireStateChannel('sensorKey', 'stateKey', 'states.mat', ... + % 'XVar', 'state_time', 'YVar', 'state_val') + if ~obj.catalog_.isKey(sensorKey) + error('ExternalSensorRegistry:unknownKey', ... + 'Cannot wire state to ''%s'': not registered in ''%s''.', ... + sensorKey, obj.Name); + end + + p = inputParser(); + p.addParameter('XVar', 'X', @ischar); + p.addParameter('YVar', 'Y', @ischar); + p.parse(varargin{:}); + + % Create StateChannel + % Note: For different-file state channels, the caller must populate + % sc.X and sc.Y manually (or via MatFileDataSource with state vars), + % because StateChannel.load() is not yet implemented. + sc = StateChannel(stateKey, 'MatFile', matFilePath, ... + 'KeyName', p.Results.YVar); + + % Attach to sensor + s = obj.catalog_(sensorKey); + s.addStateChannel(sc); + + % If same file as sensor data, update existing DataSource + if obj.dsMap_.has(sensorKey) + ds = obj.dsMap_.get(sensorKey); + if strcmp(ds.FilePath, matFilePath) + ds.StateXVar = p.Results.XVar; + ds.StateYVar = p.Results.YVar; + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (21 tests — wireStateChannel) + +- [ ] **Step 5: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add wireStateChannel to ExternalSensorRegistry" +``` + +--- + +## Chunk 3: Viewer and Integration + +### Task 6: Test and implement — viewer + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` +- Modify: `libs/SensorThreshold/ExternalSensorRegistry.m` + +- [ ] **Step 1: Add failing test** + +Add to `methods (Test)`: + +```matlab +function testViewer(testCase) + reg = ExternalSensorRegistry('TestLab'); + reg.register('temp', Sensor('temp', 'Name', 'Temperature', 'ID', 1)); + hFig = reg.viewer(); + testCase.addTeardown(@close, hFig); + testCase.verifyTrue(ishandle(hFig), 'returns_figure'); +end + +function testViewerEmpty(testCase) + reg = ExternalSensorRegistry('TestLab'); + hFig = reg.viewer(); + testCase.addTeardown(@close, hFig); + testCase.verifyTrue(ishandle(hFig), 'handles_empty'); +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: FAIL — `viewer` not defined + +- [ ] **Step 3: Implement viewer** + +Add to the `methods` block of `ExternalSensorRegistry.m`. Follow the same pattern as `SensorRegistry.viewer()` (see `libs/SensorThreshold/SensorRegistry.m:158-216`), but operate on `obj.catalog_` and include `obj.Name` in the figure title: + +```matlab +function hFig = viewer(obj) + %VIEWER Open a GUI figure showing all registered sensors. + ks = sort(obj.catalog_.keys()); + nSensors = numel(ks); + + colNames = {'Key', 'Name', 'ID', 'Source', 'MatFile', '#States', '#Rules', '#Points'}; + data = cell(nSensors, numel(colNames)); + for i = 1:nSensors + s = obj.catalog_(ks{i}); + data{i,1} = ks{i}; + data{i,2} = s.Name; + if isempty(s.ID); data{i,3} = ''; else; data{i,3} = s.ID; end + data{i,4} = s.Source; + data{i,5} = s.MatFile; + data{i,6} = numel(s.StateChannels); + data{i,7} = numel(s.ThresholdRules); + data{i,8} = numel(s.X); + end + + hFig = figure('Name', sprintf('%s — Sensor Registry', obj.Name), ... + 'NumberTitle', 'off', ... + 'Position', [200 200 900 400], ... + 'Color', [0.15 0.15 0.18], ... + 'MenuBar', 'none', 'ToolBar', 'none'); + + uicontrol('Parent', hFig, 'Style', 'text', ... + 'String', sprintf('%s (%d sensors)', obj.Name, nSensors), ... + 'Units', 'normalized', 'Position', [0.02 0.92 0.96 0.06], ... + 'BackgroundColor', [0.15 0.15 0.18], ... + 'ForegroundColor', [0.9 0.9 0.9], ... + 'FontSize', 14, 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'left'); + + colWidths = {140, 180, 50, 140, 140, 55, 50, 60}; + uitable('Parent', hFig, ... + 'Data', data, 'ColumnName', colNames, ... + 'ColumnWidth', colWidths, ... + 'Units', 'normalized', 'Position', [0.02 0.02 0.96 0.88], ... + 'RowName', [], ... + 'BackgroundColor', [0.22 0.22 0.25; 0.18 0.18 0.21], ... + 'ForegroundColor', [0.9 0.9 0.9], 'FontSize', 11); +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (23 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m libs/SensorThreshold/ExternalSensorRegistry.m +git commit -m "feat: add viewer to ExternalSensorRegistry" +``` + +--- + +### Task 7: Integration test — LiveEventPipeline round-trip + +**Files:** +- Modify: `tests/suite/TestExternalSensorRegistry.m` + +- [ ] **Step 1: Add integration test** + +This test verifies that `ExternalSensorRegistry` produces outputs compatible with `LiveEventPipeline`: + +```matlab +function testLivePipelineCompatibility(testCase) + % Create .mat file with sensor data + time = linspace(now - 1, now, 100); + temp = randn(1, 100) * 5 + 50; + matPath = fullfile(testCase.TempDir, 'live.mat'); + save(matPath, 'time', 'temp'); + + % Build registry + reg = ExternalSensorRegistry('IntegrationTest'); + s = Sensor('temp', 'Name', 'Temperature', 'Units', 'degC'); + s.addThresholdRule(struct(), 60, 'Direction', 'upper', 'Label', 'Warning'); + reg.register('temp', s); + reg.wireMatFile(matPath, {'temp', 'XVar', 'time', 'YVar', 'temp'}); + + % Verify outputs are the right types for LiveEventPipeline + dsMap = reg.getDataSourceMap(); + sensors = reg.getAll(); + + testCase.verifyTrue(isa(dsMap, 'DataSourceMap'), 'dsMap_type'); + testCase.verifyTrue(isa(sensors, 'containers.Map'), 'sensors_type'); + + % Verify DataSource can fetch data + ds = dsMap.get('temp'); + result = ds.fetchNew(); + testCase.verifyTrue(result.changed, 'fetched_data'); + testCase.verifyEqual(numel(result.X), 100, 'all_points'); +end +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `matlab -batch "install(); results = runtests('tests/suite/TestExternalSensorRegistry'); disp(results)"` +Expected: PASS (24 tests) + +- [ ] **Step 3: Commit** + +```bash +git add tests/suite/TestExternalSensorRegistry.m +git commit -m "test: add LiveEventPipeline compatibility integration test" +``` From 683b64efedcceb0d61a99e9cba35dd09535b23de Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:43:10 +0100 Subject: [PATCH 05/12] docs: add Phase B (new widgets) implementation plan 7-task plan covering HeatmapWidget, BarChartWidget, HistogramWidget, ScatterWidget, ImageWidget, MultiStatusWidget + engine/serializer/bridge registration. Tasks 1-6 are parallelizable. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-03-18-dashboard-new-widgets-phase-b.md | 1148 +++++++++++++++++ 1 file changed, 1148 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-18-dashboard-new-widgets-phase-b.md diff --git a/docs/superpowers/plans/2026-03-18-dashboard-new-widgets-phase-b.md b/docs/superpowers/plans/2026-03-18-dashboard-new-widgets-phase-b.md new file mode 100644 index 00000000..057d2e4b --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-dashboard-new-widgets-phase-b.md @@ -0,0 +1,1148 @@ +# Dashboard New Widgets (Phase B) Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 6 new widget types to the dashboard: HeatmapWidget, BarChartWidget, HistogramWidget, ScatterWidget, ImageWidget, and MultiStatusWidget. + +**Architecture:** Each widget extends DashboardWidget, follows the Sensor-first data binding pattern (Sensor → DataFcn/ValueFcn → static fallback), implements render/refresh/getType/toStruct/fromStruct, and uses base MATLAB/Octave graphics (axes, bar, imagesc, patch, text). All 6 are registered in DashboardEngine/DashboardSerializer/bridge in a single final task. + +**Tech Stack:** MATLAB/Octave, pure figure-based UI, R2020b compatible, no App Designer. + +**Spec:** `docs/superpowers/specs/2026-03-18-dashboard-grouping-and-widgets-design.md` (Phase B section) + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `libs/Dashboard/HeatmapWidget.m` | 2D color grid visualization | +| Create | `libs/Dashboard/BarChartWidget.m` | Categorical bar charts | +| Create | `libs/Dashboard/HistogramWidget.m` | Value distribution bins | +| Create | `libs/Dashboard/ScatterWidget.m` | X vs Y correlation plot | +| Create | `libs/Dashboard/ImageWidget.m` | Static image display | +| Create | `libs/Dashboard/MultiStatusWidget.m` | Multi-sensor status grid | +| Create | `tests/suite/TestHeatmapWidget.m` | Heatmap tests | +| Create | `tests/suite/TestBarChartWidget.m` | BarChart tests | +| Create | `tests/suite/TestHistogramWidget.m` | Histogram tests | +| Create | `tests/suite/TestScatterWidget.m` | Scatter tests | +| Create | `tests/suite/TestImageWidget.m` | Image tests | +| Create | `tests/suite/TestMultiStatusWidget.m` | MultiStatus tests | +| Modify | `libs/Dashboard/DashboardEngine.m` | Add 6 new cases to addWidget + widgetTypes | +| Modify | `libs/Dashboard/DashboardSerializer.m` | Add 6 new cases to createWidgetFromStruct + exportScript | +| Modify | `bridge/web/js/widgets.js` | Add 6 new render functions | + +--- + +## Widget Implementation Template + +Every widget task follows this exact pattern. The task text below specifies **only the differences** from this template. + +**Constructor:** +```matlab +function obj = MyWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + % Override default position if needed + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 6 3]; % widget-specific default + end +end +``` + +**getType:** Returns lowercase type string (e.g., `'heatmap'`). + +**toStruct:** Calls `s = toStruct@DashboardWidget(obj)` then adds widget-specific fields in lowercase. For DataFcn/ValueFcn sources, serializes as `s.source = struct('type', 'callback', 'function', func2str(obj.DataFcn))`. + +**fromStruct:** Static. Creates widget, sets Position from `s.position.{col,row,width,height}`, Title from `s.title`, and widget-specific properties. Resolves Sensor via SensorRegistry if available. + +**Test pattern:** Each test file has TestClassSetup calling `install()`, then tests for: construction with defaults, construction with Sensor, getType, toStruct round-trip, and render into offscreen figure. + +--- + +## Chunk 1: Chart Widgets (Heatmap, BarChart, Histogram, Scatter) + +### Task 1: HeatmapWidget + +**Files:** Create `libs/Dashboard/HeatmapWidget.m`, Create `tests/suite/TestHeatmapWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestHeatmapWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = HeatmapWidget(); + testCase.verifyEqual(w.getType(), 'heatmap'); + testCase.verifyEqual(w.Colormap, 'parula'); + testCase.verifyEqual(w.ShowColorbar, true); + end + + function testRender(testCase) + w = HeatmapWidget('Title', 'Test Heatmap'); + w.DataFcn = @() magic(5); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStructRoundTrip(testCase) + w = HeatmapWidget('Title', 'Heat'); + w.Colormap = 'jet'; + w.ShowColorbar = false; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'heatmap'); + testCase.verifyEqual(s.colormap, 'jet'); + testCase.verifyEqual(s.showColorbar, false); + end + end +end +``` + +- [ ] **Step 2: Write HeatmapWidget.m** + +```matlab +classdef HeatmapWidget < DashboardWidget + properties (Access = public) + DataFcn = [] % function_handle returning matrix + Colormap = 'parula' % colormap name or Nx3 matrix + ShowColorbar = true + XLabels = {} % cell array of axis labels + YLabels = {} % cell array of axis labels + end + + properties (SetAccess = private) + hAxes = [] + hImage = [] + hColorbar = [] + end + + methods + function obj = HeatmapWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + bg = theme.WidgetBackground; + + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.1 0.1 0.8 0.8], ... + 'Color', bg, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y; + elseif ~isempty(obj.DataFcn) + data = obj.DataFcn(); + end + if isempty(data), return; end + + % Ensure data is 2D matrix + if isvector(data) + data = data(:)'; + end + + obj.hImage = imagesc(obj.hAxes, data); + colormap(obj.hAxes, obj.Colormap); + if obj.ShowColorbar + obj.hColorbar = colorbar(obj.hAxes); + end + if ~isempty(obj.XLabels) + set(obj.hAxes, 'XTick', 1:numel(obj.XLabels), ... + 'XTickLabel', obj.XLabels); + end + if ~isempty(obj.YLabels) + set(obj.hAxes, 'YTick', 1:numel(obj.YLabels), ... + 'YTickLabel', obj.YLabels); + end + end + + function t = getType(~) + t = 'heatmap'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.colormap = obj.Colormap; + s.showColorbar = obj.ShowColorbar; + if ~isempty(obj.XLabels), s.xLabels = obj.XLabels; end + if ~isempty(obj.YLabels), s.yLabels = obj.YLabels; end + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = HeatmapWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'colormap'), obj.Colormap = s.colormap; end + if isfield(s, 'showColorbar'), obj.ShowColorbar = s.showColorbar; end + if isfield(s, 'xLabels'), obj.XLabels = s.xLabels; end + if isfield(s, 'yLabels'), obj.YLabels = s.yLabels; end + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/HeatmapWidget.m tests/suite/TestHeatmapWidget.m +git commit -m "feat(dashboard): add HeatmapWidget for 2D color grid visualization" +``` + +--- + +### Task 2: BarChartWidget + +**Files:** Create `libs/Dashboard/BarChartWidget.m`, Create `tests/suite/TestBarChartWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestBarChartWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = BarChartWidget(); + testCase.verifyEqual(w.getType(), 'barchart'); + testCase.verifyEqual(w.Orientation, 'vertical'); + testCase.verifyEqual(w.Stacked, false); + end + + function testRender(testCase) + w = BarChartWidget('Title', 'Test Bar'); + w.DataFcn = @() struct('categories', {{'A','B','C'}}, 'values', [10 20 30]); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = BarChartWidget('Title', 'Bar'); + w.Orientation = 'horizontal'; + w.Stacked = true; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'barchart'); + testCase.verifyEqual(s.orientation, 'horizontal'); + testCase.verifyEqual(s.stacked, true); + end + end +end +``` + +- [ ] **Step 2: Write BarChartWidget.m** + +```matlab +classdef BarChartWidget < DashboardWidget + properties (Access = public) + DataFcn = [] % @() struct('categories',{},'values',[]) + Orientation = 'vertical' % 'vertical' or 'horizontal' + Stacked = false + end + + properties (SetAccess = private) + hAxes = [] + hBars = [] + end + + methods + function obj = BarChartWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + cats = {}; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y; + elseif ~isempty(obj.DataFcn) + result = obj.DataFcn(); + if isstruct(result) + cats = result.categories; + data = result.values; + else + data = result; + end + end + if isempty(data), return; end + + cla(obj.hAxes); + if strcmp(obj.Orientation, 'horizontal') + obj.hBars = barh(obj.hAxes, data); + else + obj.hBars = bar(obj.hAxes, data); + end + if ~isempty(cats) + if strcmp(obj.Orientation, 'horizontal') + set(obj.hAxes, 'YTick', 1:numel(cats), 'YTickLabel', cats); + else + set(obj.hAxes, 'XTick', 1:numel(cats), 'XTickLabel', cats); + end + end + end + + function t = getType(~) + t = 'barchart'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.orientation = obj.Orientation; + s.stacked = obj.Stacked; + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = BarChartWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'orientation'), obj.Orientation = s.orientation; end + if isfield(s, 'stacked'), obj.Stacked = s.stacked; end + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/BarChartWidget.m tests/suite/TestBarChartWidget.m +git commit -m "feat(dashboard): add BarChartWidget for categorical bar charts" +``` + +--- + +### Task 3: HistogramWidget + +**Files:** Create `libs/Dashboard/HistogramWidget.m`, Create `tests/suite/TestHistogramWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestHistogramWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = HistogramWidget(); + testCase.verifyEqual(w.getType(), 'histogram'); + testCase.verifyEqual(w.ShowNormalFit, false); + testCase.verifyEmpty(w.NumBins); + end + + function testRender(testCase) + w = HistogramWidget('Title', 'Test Hist'); + w.DataFcn = @() randn(1, 100); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = HistogramWidget('Title', 'Hist'); + w.NumBins = 20; + w.ShowNormalFit = true; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'histogram'); + testCase.verifyEqual(s.numBins, 20); + testCase.verifyEqual(s.showNormalFit, true); + end + end +end +``` + +- [ ] **Step 2: Write HistogramWidget.m** + +Uses `bar` on computed bin edges (not `histogram()`) for Octave compatibility: + +```matlab +classdef HistogramWidget < DashboardWidget + properties (Access = public) + DataFcn = [] + NumBins = [] % empty = auto + ShowNormalFit = false + EdgeColor = [] % RGB or empty for default + end + + properties (SetAccess = private) + hAxes = [] + end + + methods + function obj = HistogramWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y(:)'; + elseif ~isempty(obj.DataFcn) + data = obj.DataFcn(); + data = data(:)'; + end + if isempty(data), return; end + + nBins = obj.NumBins; + if isempty(nBins) + nBins = max(10, round(sqrt(numel(data)))); + end + + [counts, edges] = histcounts(data, nBins); + centers = (edges(1:end-1) + edges(2:end)) / 2; + + cla(obj.hAxes); + bar(obj.hAxes, centers, counts, 1); + + if obj.ShowNormalFit && numel(data) > 2 + hold(obj.hAxes, 'on'); + mu = mean(data); + sigma = std(data); + xFit = linspace(min(data), max(data), 100); + binWidth = edges(2) - edges(1); + yFit = numel(data) * binWidth * ... + (1 / (sigma * sqrt(2*pi))) * exp(-0.5 * ((xFit - mu) / sigma).^2); + plot(obj.hAxes, xFit, yFit, 'r-', 'LineWidth', 1.5); + hold(obj.hAxes, 'off'); + end + end + + function t = getType(~) + t = 'histogram'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + if ~isempty(obj.NumBins), s.numBins = obj.NumBins; end + s.showNormalFit = obj.ShowNormalFit; + if ~isempty(obj.EdgeColor), s.edgeColor = obj.EdgeColor; end + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = HistogramWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'numBins'), obj.NumBins = s.numBins; end + if isfield(s, 'showNormalFit'), obj.ShowNormalFit = s.showNormalFit; end + if isfield(s, 'edgeColor'), obj.EdgeColor = s.edgeColor; end + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/HistogramWidget.m tests/suite/TestHistogramWidget.m +git commit -m "feat(dashboard): add HistogramWidget for value distributions" +``` + +--- + +### Task 4: ScatterWidget + +**Files:** Create `libs/Dashboard/ScatterWidget.m`, Create `tests/suite/TestScatterWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestScatterWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = ScatterWidget(); + testCase.verifyEqual(w.getType(), 'scatter'); + testCase.verifyEqual(w.MarkerSize, 6); + testCase.verifyEqual(w.Colormap, 'parula'); + end + + function testToStruct(testCase) + w = ScatterWidget('Title', 'Scatter'); + w.MarkerSize = 10; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'scatter'); + testCase.verifyEqual(s.markerSize, 10); + end + end +end +``` + +- [ ] **Step 2: Write ScatterWidget.m** + +Uses two Sensor properties (SensorX, SensorY) instead of the base Sensor: + +```matlab +classdef ScatterWidget < DashboardWidget + properties (Access = public) + SensorX = [] % Sensor for X axis + SensorY = [] % Sensor for Y axis + SensorColor = [] % Optional: color-code by third sensor + MarkerSize = 6 + Colormap = 'parula' + end + + properties (SetAccess = private) + hAxes = [] + hScatter = [] + end + + methods + function obj = ScatterWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + xData = []; + yData = []; + if ~isempty(obj.SensorX) && ~isempty(obj.SensorY) + if isempty(obj.SensorX.Y) || isempty(obj.SensorY.Y), return; end + n = min(numel(obj.SensorX.Y), numel(obj.SensorY.Y)); + xData = obj.SensorX.Y(1:n); + yData = obj.SensorY.Y(1:n); + end + if isempty(xData), return; end + + cla(obj.hAxes); + if ~isempty(obj.SensorColor) && ~isempty(obj.SensorColor.Y) + cData = obj.SensorColor.Y(1:min(numel(obj.SensorColor.Y), numel(xData))); + % Use line with markers for Octave compatibility + obj.hScatter = scatter(obj.hAxes, xData, yData, obj.MarkerSize, cData, 'filled'); + colormap(obj.hAxes, obj.Colormap); + colorbar(obj.hAxes); + else + obj.hScatter = line(xData, yData, ... + 'Parent', obj.hAxes, ... + 'LineStyle', 'none', ... + 'Marker', '.', ... + 'MarkerSize', obj.MarkerSize); + end + end + + function t = getType(~) + t = 'scatter'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.markerSize = obj.MarkerSize; + s.colormap = obj.Colormap; + % Override source with dual-sensor info + if ~isempty(obj.SensorX) + s.sensorX = obj.SensorX.Key; + end + if ~isempty(obj.SensorY) + s.sensorY = obj.SensorY.Key; + end + if ~isempty(obj.SensorColor) + s.sensorColor = obj.SensorColor.Key; + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = ScatterWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'markerSize'), obj.MarkerSize = s.markerSize; end + if isfield(s, 'colormap'), obj.Colormap = s.colormap; end + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/ScatterWidget.m tests/suite/TestScatterWidget.m +git commit -m "feat(dashboard): add ScatterWidget for sensor correlation plots" +``` + +--- + +## Chunk 2: Content Widgets (Image, MultiStatus) + Registration + +### Task 5: ImageWidget + +**Files:** Create `libs/Dashboard/ImageWidget.m`, Create `tests/suite/TestImageWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestImageWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = ImageWidget(); + testCase.verifyEqual(w.getType(), 'image'); + testCase.verifyEqual(w.Scaling, 'fit'); + end + + function testRenderWithImageFcn(testCase) + w = ImageWidget('Title', 'Test Image'); + w.ImageFcn = @() uint8(randi(255, 50, 50, 3)); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = ImageWidget('Title', 'Img'); + w.File = '/tmp/test.png'; + w.Caption = 'A test image'; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'image'); + testCase.verifyEqual(s.file, '/tmp/test.png'); + testCase.verifyEqual(s.caption, 'A test image'); + end + end +end +``` + +- [ ] **Step 2: Write ImageWidget.m** + +```matlab +classdef ImageWidget < DashboardWidget + properties (Access = public) + File = '' % Path to image file (PNG, JPG) + ImageFcn = [] % function_handle returning image matrix + Scaling = 'fit' % 'fit', 'fill', 'stretch' + Caption = '' + end + + properties (SetAccess = private) + hAxes = [] + hImage = [] + hCaption = [] + end + + methods + function obj = ImageWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 6 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + + captionH = 0; + if ~isempty(obj.Caption) + captionH = 0.08; + end + + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.02 captionH+0.02 0.96 0.96-captionH], ... + 'Visible', 'off'); + + if ~isempty(obj.Caption) + obj.hCaption = uicontrol(parentPanel, ... + 'Style', 'text', ... + 'String', obj.Caption, ... + 'Units', 'normalized', ... + 'Position', [0.02 0 0.96 captionH], ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 9, ... + 'ForegroundColor', theme.AxisColor, ... + 'BackgroundColor', theme.WidgetBackground); + end + + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + imgData = []; + if ~isempty(obj.File) && exist(obj.File, 'file') + imgData = imread(obj.File); + elseif ~isempty(obj.ImageFcn) + imgData = obj.ImageFcn(); + end + if isempty(imgData), return; end + + obj.hImage = image(obj.hAxes, imgData); + axis(obj.hAxes, 'image'); + set(obj.hAxes, 'Visible', 'off'); + end + + function t = getType(~) + t = 'image'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + if ~isempty(obj.File), s.file = obj.File; end + if ~isempty(obj.Caption), s.caption = obj.Caption; end + s.scaling = obj.Scaling; + if ~isempty(obj.ImageFcn) && isempty(obj.File) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.ImageFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = ImageWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'file'), obj.File = s.file; end + if isfield(s, 'caption'), obj.Caption = s.caption; end + if isfield(s, 'scaling'), obj.Scaling = s.scaling; end + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/ImageWidget.m tests/suite/TestImageWidget.m +git commit -m "feat(dashboard): add ImageWidget for static image display" +``` + +--- + +### Task 6: MultiStatusWidget + +**Files:** Create `libs/Dashboard/MultiStatusWidget.m`, Create `tests/suite/TestMultiStatusWidget.m` + +- [ ] **Step 1: Write test file** + +```matlab +classdef TestMultiStatusWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = MultiStatusWidget(); + testCase.verifyEqual(w.getType(), 'multistatus'); + testCase.verifyEqual(w.ShowLabels, true); + testCase.verifyEqual(w.IconStyle, 'dot'); + end + + function testToStruct(testCase) + w = MultiStatusWidget('Title', 'Status Grid'); + w.Columns = 4; + w.IconStyle = 'square'; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'multistatus'); + testCase.verifyEqual(s.columns, 4); + testCase.verifyEqual(s.iconStyle, 'square'); + end + end +end +``` + +- [ ] **Step 2: Write MultiStatusWidget.m** + +Note: Uses `Sensors` (plural) array instead of inherited `Sensor` (singular). Fully overrides `toStruct`. + +```matlab +classdef MultiStatusWidget < DashboardWidget + properties (Access = public) + Sensors = {} % Cell array of Sensor objects + Columns = [] % Grid columns (empty = auto) + ShowLabels = true + IconStyle = 'dot' % 'dot', 'square', 'icon' + end + + properties (SetAccess = private) + hAxes = [] + end + + methods + function obj = MultiStatusWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 3]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.02 0.02 0.96 0.96], ... + 'Visible', 'off', ... + 'XLim', [0 1], 'YLim', [0 1]); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + n = numel(obj.Sensors); + if n == 0, return; end + + cols = obj.Columns; + if isempty(cols) + cols = ceil(sqrt(n)); + end + rows = ceil(n / cols); + + cla(obj.hAxes); + hold(obj.hAxes, 'on'); + + theme = obj.getTheme(); + okColor = theme.StatusOkColor; + warnColor = theme.StatusWarnColor; + alarmColor = theme.StatusAlarmColor; + + for i = 1:n + col = mod(i-1, cols); + row = floor((i-1) / cols); + + cx = (col + 0.5) / cols; + cy = 1 - (row + 0.5) / rows; + + % Determine color from sensor thresholds + sensor = obj.Sensors{i}; + color = okColor; + if ~isempty(sensor) && ~isempty(sensor.Y) + val = sensor.Y(end); + if ~isempty(sensor.ThresholdRules) + for k = 1:numel(sensor.ThresholdRules) + rule = sensor.ThresholdRules{k}; + if ~isempty(rule.Color) + if rule.IsUpper && val >= rule.Value + color = rule.Color; + elseif ~rule.IsUpper && val <= rule.Value + color = rule.Color; + end + end + end + end + end + + % Draw indicator + r = 0.3 / max(cols, rows); + if strcmp(obj.IconStyle, 'square') + rectangle(obj.hAxes, 'Position', [cx-r cy-r 2*r 2*r], ... + 'FaceColor', color, 'EdgeColor', 'none'); + else + theta = linspace(0, 2*pi, 30); + fill(obj.hAxes, cx + r*cos(theta), cy + r*sin(theta), ... + color, 'EdgeColor', 'none'); + end + + % Label + if obj.ShowLabels && ~isempty(sensor) + name = sensor.Name; + if isempty(name), name = sensor.Key; end + text(obj.hAxes, cx, cy - r - 0.02, name, ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 8, ... + 'Color', theme.AxisColor); + end + end + hold(obj.hAxes, 'off'); + end + + function t = getType(~) + t = 'multistatus'; + end + + function s = toStruct(obj) + % Fully override — does not use base Sensor property + s = struct(); + s.type = 'multistatus'; + s.title = obj.Title; + s.description = obj.Description; + s.position = struct('col', obj.Position(1), 'row', obj.Position(2), ... + 'width', obj.Position(3), 'height', obj.Position(4)); + if ~isempty(fieldnames(obj.ThemeOverride)) + s.themeOverride = obj.ThemeOverride; + end + s.columns = obj.Columns; + s.showLabels = obj.ShowLabels; + s.iconStyle = obj.IconStyle; + % Serialize sensor keys + keys = cell(1, numel(obj.Sensors)); + for i = 1:numel(obj.Sensors) + keys{i} = obj.Sensors{i}.Key; + end + s.sensors = keys; + end + end + + methods (Static) + function obj = fromStruct(s) + obj = MultiStatusWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'columns'), obj.Columns = s.columns; end + if isfield(s, 'showLabels'), obj.ShowLabels = s.showLabels; end + if isfield(s, 'iconStyle'), obj.IconStyle = s.iconStyle; end + % Sensor resolution happens via resolver in configToWidgets + end + end +end +``` + +- [ ] **Step 3: Verify tests pass, commit** + +```bash +git add libs/Dashboard/MultiStatusWidget.m tests/suite/TestMultiStatusWidget.m +git commit -m "feat(dashboard): add MultiStatusWidget for multi-sensor status grid" +``` + +--- + +### Task 7: Register all 6 widgets in Engine, Serializer, and Bridge + +**Files:** +- Modify: `libs/Dashboard/DashboardEngine.m` +- Modify: `libs/Dashboard/DashboardSerializer.m` +- Modify: `bridge/web/js/widgets.js` + +- [ ] **Step 1: Add 6 cases to DashboardEngine.addWidget** + +In the `addWidget` switch block, add before `otherwise`: + +```matlab +case 'heatmap' + w = HeatmapWidget(varargin{:}); +case 'barchart' + w = BarChartWidget(varargin{:}); +case 'histogram' + w = HistogramWidget(varargin{:}); +case 'scatter' + w = ScatterWidget(varargin{:}); +case 'image' + w = ImageWidget(varargin{:}); +case 'multistatus' + w = MultiStatusWidget(varargin{:}); +``` + +Add to `widgetTypes()`: + +```matlab +'heatmap', 'Heatmap color grid (HeatmapWidget)' +'barchart', 'Bar chart for categories (BarChartWidget)' +'histogram', 'Value distribution histogram (HistogramWidget)' +'scatter', 'X vs Y scatter plot (ScatterWidget)' +'image', 'Static image display (ImageWidget)' +'multistatus', 'Multi-sensor status grid (MultiStatusWidget)' +``` + +- [ ] **Step 2: Add 6 cases to DashboardSerializer.createWidgetFromStruct** + +```matlab +case 'heatmap' + w = HeatmapWidget.fromStruct(ws); +case 'barchart' + w = BarChartWidget.fromStruct(ws); +case 'histogram' + w = HistogramWidget.fromStruct(ws); +case 'scatter' + w = ScatterWidget.fromStruct(ws); +case 'image' + w = ImageWidget.fromStruct(ws); +case 'multistatus' + w = MultiStatusWidget.fromStruct(ws); +``` + +Also add cases to `exportScript`. + +- [ ] **Step 3: Add 6 render functions to widgets.js** + +Add dispatch cases and simple render functions for web export. Each function creates a placeholder or basic HTML representation. + +- [ ] **Step 4: Commit** + +```bash +git add libs/Dashboard/DashboardEngine.m libs/Dashboard/DashboardSerializer.m bridge/web/js/widgets.js +git commit -m "feat(dashboard): register all 6 new widget types in engine, serializer, and bridge" +``` + +--- + +## Summary + +| Task | Widget | Key Feature | +|------|--------|-------------| +| 1 | HeatmapWidget | imagesc + colorbar, DataFcn or Sensor | +| 2 | BarChartWidget | bar/barh, orientation, stacked | +| 3 | HistogramWidget | bar on histcounts, optional normal fit | +| 4 | ScatterWidget | Dual SensorX/SensorY, optional color sensor | +| 5 | ImageWidget | imread from file or ImageFcn | +| 6 | MultiStatusWidget | Sensor array, colored dots/squares grid | +| 7 | Registration | Engine + Serializer + Bridge for all 6 | + +**Parallelization:** Tasks 1-6 are fully independent — all 6 widgets can be implemented in parallel. Task 7 depends on all 6 being complete. From 8e73aef469205332179f83c26617ad0ba28a6e71 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:44:12 +0100 Subject: [PATCH 06/12] feat(dashboard): add BarChartWidget for categorical bar charts Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/Dashboard/BarChartWidget.m | 97 ++++++++++++++++++++++++++++++++ tests/suite/TestBarChartWidget.m | 39 +++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 libs/Dashboard/BarChartWidget.m create mode 100644 tests/suite/TestBarChartWidget.m diff --git a/libs/Dashboard/BarChartWidget.m b/libs/Dashboard/BarChartWidget.m new file mode 100644 index 00000000..ed5549d2 --- /dev/null +++ b/libs/Dashboard/BarChartWidget.m @@ -0,0 +1,97 @@ +classdef BarChartWidget < DashboardWidget + properties (Access = public) + DataFcn = [] % @() struct('categories',{},'values',[]) + Orientation = 'vertical' % 'vertical' or 'horizontal' + Stacked = false + end + + properties (SetAccess = private) + hAxes = [] + hBars = [] + end + + methods + function obj = BarChartWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + cats = {}; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y; + elseif ~isempty(obj.DataFcn) + result = obj.DataFcn(); + if isstruct(result) + cats = result.categories; + data = result.values; + else + data = result; + end + end + if isempty(data), return; end + + cla(obj.hAxes); + if strcmp(obj.Orientation, 'horizontal') + obj.hBars = barh(obj.hAxes, data); + else + obj.hBars = bar(obj.hAxes, data); + end + if ~isempty(cats) + if strcmp(obj.Orientation, 'horizontal') + set(obj.hAxes, 'YTick', 1:numel(cats), 'YTickLabel', cats); + else + set(obj.hAxes, 'XTick', 1:numel(cats), 'XTickLabel', cats); + end + end + end + + function t = getType(~) + t = 'barchart'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.orientation = obj.Orientation; + s.stacked = obj.Stacked; + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = BarChartWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'orientation'), obj.Orientation = s.orientation; end + if isfield(s, 'stacked'), obj.Stacked = s.stacked; end + end + end +end diff --git a/tests/suite/TestBarChartWidget.m b/tests/suite/TestBarChartWidget.m new file mode 100644 index 00000000..62d499a4 --- /dev/null +++ b/tests/suite/TestBarChartWidget.m @@ -0,0 +1,39 @@ +classdef TestBarChartWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = BarChartWidget(); + testCase.verifyEqual(w.getType(), 'barchart'); + testCase.verifyEqual(w.Orientation, 'vertical'); + testCase.verifyEqual(w.Stacked, false); + end + + function testRender(testCase) + w = BarChartWidget('Title', 'Test Bar'); + w.DataFcn = @() struct('categories', {{'A','B','C'}}, 'values', [10 20 30]); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = BarChartWidget('Title', 'Bar'); + w.Orientation = 'horizontal'; + w.Stacked = true; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'barchart'); + testCase.verifyEqual(s.orientation, 'horizontal'); + testCase.verifyEqual(s.stacked, true); + end + end +end From 8259ec8d42d4985e81ccfb00deb4510c829ea11d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:44:16 +0100 Subject: [PATCH 07/12] feat(dashboard): add HeatmapWidget for 2D color grid visualization --- libs/Dashboard/HeatmapWidget.m | 105 ++++++++++++++++++++++++++++++ libs/Dashboard/HistogramWidget.m | 102 +++++++++++++++++++++++++++++ libs/Dashboard/ScatterWidget.m | 100 ++++++++++++++++++++++++++++ tests/suite/TestHeatmapWidget.m | 39 +++++++++++ tests/suite/TestHistogramWidget.m | 39 +++++++++++ tests/suite/TestScatterWidget.m | 25 +++++++ 6 files changed, 410 insertions(+) create mode 100644 libs/Dashboard/HeatmapWidget.m create mode 100644 libs/Dashboard/HistogramWidget.m create mode 100644 libs/Dashboard/ScatterWidget.m create mode 100644 tests/suite/TestHeatmapWidget.m create mode 100644 tests/suite/TestHistogramWidget.m create mode 100644 tests/suite/TestScatterWidget.m diff --git a/libs/Dashboard/HeatmapWidget.m b/libs/Dashboard/HeatmapWidget.m new file mode 100644 index 00000000..70c3d76d --- /dev/null +++ b/libs/Dashboard/HeatmapWidget.m @@ -0,0 +1,105 @@ +classdef HeatmapWidget < DashboardWidget + properties (Access = public) + DataFcn = [] % function_handle returning matrix + Colormap = 'parula' % colormap name or Nx3 matrix + ShowColorbar = true + XLabels = {} % cell array of axis labels + YLabels = {} % cell array of axis labels + end + + properties (SetAccess = private) + hAxes = [] + hImage = [] + hColorbar = [] + end + + methods + function obj = HeatmapWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + bg = theme.WidgetBackground; + + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.1 0.1 0.8 0.8], ... + 'Color', bg, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y; + elseif ~isempty(obj.DataFcn) + data = obj.DataFcn(); + end + if isempty(data), return; end + + % Ensure data is 2D matrix + if isvector(data) + data = data(:)'; + end + + obj.hImage = imagesc(obj.hAxes, data); + colormap(obj.hAxes, obj.Colormap); + if obj.ShowColorbar + obj.hColorbar = colorbar(obj.hAxes); + end + if ~isempty(obj.XLabels) + set(obj.hAxes, 'XTick', 1:numel(obj.XLabels), ... + 'XTickLabel', obj.XLabels); + end + if ~isempty(obj.YLabels) + set(obj.hAxes, 'YTick', 1:numel(obj.YLabels), ... + 'YTickLabel', obj.YLabels); + end + end + + function t = getType(~) + t = 'heatmap'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.colormap = obj.Colormap; + s.showColorbar = obj.ShowColorbar; + if ~isempty(obj.XLabels), s.xLabels = obj.XLabels; end + if ~isempty(obj.YLabels), s.yLabels = obj.YLabels; end + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = HeatmapWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'colormap'), obj.Colormap = s.colormap; end + if isfield(s, 'showColorbar'), obj.ShowColorbar = s.showColorbar; end + if isfield(s, 'xLabels'), obj.XLabels = s.xLabels; end + if isfield(s, 'yLabels'), obj.YLabels = s.yLabels; end + end + end +end diff --git a/libs/Dashboard/HistogramWidget.m b/libs/Dashboard/HistogramWidget.m new file mode 100644 index 00000000..01b245a1 --- /dev/null +++ b/libs/Dashboard/HistogramWidget.m @@ -0,0 +1,102 @@ +classdef HistogramWidget < DashboardWidget + properties (Access = public) + DataFcn = [] + NumBins = [] % empty = auto + ShowNormalFit = false + EdgeColor = [] % RGB or empty for default + end + + properties (SetAccess = private) + hAxes = [] + end + + methods + function obj = HistogramWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + data = []; + if ~isempty(obj.Sensor) + if isempty(obj.Sensor.Y), return; end + data = obj.Sensor.Y(:)'; + elseif ~isempty(obj.DataFcn) + data = obj.DataFcn(); + data = data(:)'; + end + if isempty(data), return; end + + nBins = obj.NumBins; + if isempty(nBins) + nBins = max(10, round(sqrt(numel(data)))); + end + + [counts, edges] = histcounts(data, nBins); + centers = (edges(1:end-1) + edges(2:end)) / 2; + + cla(obj.hAxes); + bar(obj.hAxes, centers, counts, 1); + + if obj.ShowNormalFit && numel(data) > 2 + hold(obj.hAxes, 'on'); + mu = mean(data); + sigma = std(data); + xFit = linspace(min(data), max(data), 100); + binWidth = edges(2) - edges(1); + yFit = numel(data) * binWidth * ... + (1 / (sigma * sqrt(2*pi))) * exp(-0.5 * ((xFit - mu) / sigma).^2); + plot(obj.hAxes, xFit, yFit, 'r-', 'LineWidth', 1.5); + hold(obj.hAxes, 'off'); + end + end + + function t = getType(~) + t = 'histogram'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + if ~isempty(obj.NumBins), s.numBins = obj.NumBins; end + s.showNormalFit = obj.ShowNormalFit; + if ~isempty(obj.EdgeColor), s.edgeColor = obj.EdgeColor; end + if ~isempty(obj.DataFcn) && isempty(obj.Sensor) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.DataFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = HistogramWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'numBins'), obj.NumBins = s.numBins; end + if isfield(s, 'showNormalFit'), obj.ShowNormalFit = s.showNormalFit; end + if isfield(s, 'edgeColor'), obj.EdgeColor = s.edgeColor; end + end + end +end diff --git a/libs/Dashboard/ScatterWidget.m b/libs/Dashboard/ScatterWidget.m new file mode 100644 index 00000000..368a0441 --- /dev/null +++ b/libs/Dashboard/ScatterWidget.m @@ -0,0 +1,100 @@ +classdef ScatterWidget < DashboardWidget + properties (Access = public) + SensorX = [] % Sensor for X axis + SensorY = [] % Sensor for Y axis + SensorColor = [] % Optional: color-code by third sensor + MarkerSize = 6 + Colormap = 'parula' + end + + properties (SetAccess = private) + hAxes = [] + hScatter = [] + end + + methods + function obj = ScatterWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.12 0.15 0.82 0.75], ... + 'Color', theme.WidgetBackground, ... + 'XColor', theme.AxisColor, ... + 'YColor', theme.AxisColor); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + xData = []; + yData = []; + if ~isempty(obj.SensorX) && ~isempty(obj.SensorY) + if isempty(obj.SensorX.Y) || isempty(obj.SensorY.Y), return; end + n = min(numel(obj.SensorX.Y), numel(obj.SensorY.Y)); + xData = obj.SensorX.Y(1:n); + yData = obj.SensorY.Y(1:n); + end + if isempty(xData), return; end + + cla(obj.hAxes); + if ~isempty(obj.SensorColor) && ~isempty(obj.SensorColor.Y) + cData = obj.SensorColor.Y(1:min(numel(obj.SensorColor.Y), numel(xData))); + % Use line with markers for Octave compatibility + obj.hScatter = scatter(obj.hAxes, xData, yData, obj.MarkerSize, cData, 'filled'); + colormap(obj.hAxes, obj.Colormap); + colorbar(obj.hAxes); + else + obj.hScatter = line(xData, yData, ... + 'Parent', obj.hAxes, ... + 'LineStyle', 'none', ... + 'Marker', '.', ... + 'MarkerSize', obj.MarkerSize); + end + end + + function t = getType(~) + t = 'scatter'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + s.markerSize = obj.MarkerSize; + s.colormap = obj.Colormap; + % Override source with dual-sensor info + if ~isempty(obj.SensorX) + s.sensorX = obj.SensorX.Key; + end + if ~isempty(obj.SensorY) + s.sensorY = obj.SensorY.Key; + end + if ~isempty(obj.SensorColor) + s.sensorColor = obj.SensorColor.Key; + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = ScatterWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'markerSize'), obj.MarkerSize = s.markerSize; end + if isfield(s, 'colormap'), obj.Colormap = s.colormap; end + end + end +end diff --git a/tests/suite/TestHeatmapWidget.m b/tests/suite/TestHeatmapWidget.m new file mode 100644 index 00000000..bd2649bc --- /dev/null +++ b/tests/suite/TestHeatmapWidget.m @@ -0,0 +1,39 @@ +classdef TestHeatmapWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = HeatmapWidget(); + testCase.verifyEqual(w.getType(), 'heatmap'); + testCase.verifyEqual(w.Colormap, 'parula'); + testCase.verifyEqual(w.ShowColorbar, true); + end + + function testRender(testCase) + w = HeatmapWidget('Title', 'Test Heatmap'); + w.DataFcn = @() magic(5); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStructRoundTrip(testCase) + w = HeatmapWidget('Title', 'Heat'); + w.Colormap = 'jet'; + w.ShowColorbar = false; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'heatmap'); + testCase.verifyEqual(s.colormap, 'jet'); + testCase.verifyEqual(s.showColorbar, false); + end + end +end diff --git a/tests/suite/TestHistogramWidget.m b/tests/suite/TestHistogramWidget.m new file mode 100644 index 00000000..bc0f9129 --- /dev/null +++ b/tests/suite/TestHistogramWidget.m @@ -0,0 +1,39 @@ +classdef TestHistogramWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = HistogramWidget(); + testCase.verifyEqual(w.getType(), 'histogram'); + testCase.verifyEqual(w.ShowNormalFit, false); + testCase.verifyEmpty(w.NumBins); + end + + function testRender(testCase) + w = HistogramWidget('Title', 'Test Hist'); + w.DataFcn = @() randn(1, 100); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = HistogramWidget('Title', 'Hist'); + w.NumBins = 20; + w.ShowNormalFit = true; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'histogram'); + testCase.verifyEqual(s.numBins, 20); + testCase.verifyEqual(s.showNormalFit, true); + end + end +end diff --git a/tests/suite/TestScatterWidget.m b/tests/suite/TestScatterWidget.m new file mode 100644 index 00000000..25e5c589 --- /dev/null +++ b/tests/suite/TestScatterWidget.m @@ -0,0 +1,25 @@ +classdef TestScatterWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = ScatterWidget(); + testCase.verifyEqual(w.getType(), 'scatter'); + testCase.verifyEqual(w.MarkerSize, 6); + testCase.verifyEqual(w.Colormap, 'parula'); + end + + function testToStruct(testCase) + w = ScatterWidget('Title', 'Scatter'); + w.MarkerSize = 10; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'scatter'); + testCase.verifyEqual(s.markerSize, 10); + end + end +end From fbc0b40c6f942d13c55b61c7e053e840ba742ee4 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:44:23 +0100 Subject: [PATCH 08/12] feat(dashboard): add ImageWidget for static image display Co-Authored-By: Claude Sonnet 4.6 --- libs/Dashboard/ImageWidget.m | 100 ++++++++++++++++++++++++++++++++++ tests/suite/TestImageWidget.m | 38 +++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 libs/Dashboard/ImageWidget.m create mode 100644 tests/suite/TestImageWidget.m diff --git a/libs/Dashboard/ImageWidget.m b/libs/Dashboard/ImageWidget.m new file mode 100644 index 00000000..43d8ebf6 --- /dev/null +++ b/libs/Dashboard/ImageWidget.m @@ -0,0 +1,100 @@ +classdef ImageWidget < DashboardWidget + properties (Access = public) + File = '' % Path to image file (PNG, JPG) + ImageFcn = [] % function_handle returning image matrix + Scaling = 'fit' % 'fit', 'fill', 'stretch' + Caption = '' + end + + properties (SetAccess = private) + hAxes = [] + hImage = [] + hCaption = [] + end + + methods + function obj = ImageWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 6 4]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + + captionH = 0; + if ~isempty(obj.Caption) + captionH = 0.08; + end + + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.02 captionH+0.02 0.96 0.96-captionH], ... + 'Visible', 'off'); + + if ~isempty(obj.Caption) + obj.hCaption = uicontrol(parentPanel, ... + 'Style', 'text', ... + 'String', obj.Caption, ... + 'Units', 'normalized', ... + 'Position', [0.02 0 0.96 captionH], ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 9, ... + 'ForegroundColor', theme.AxisColor, ... + 'BackgroundColor', theme.WidgetBackground); + end + + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + imgData = []; + if ~isempty(obj.File) && exist(obj.File, 'file') + imgData = imread(obj.File); + elseif ~isempty(obj.ImageFcn) + imgData = obj.ImageFcn(); + end + if isempty(imgData), return; end + + obj.hImage = image(obj.hAxes, imgData); + axis(obj.hAxes, 'image'); + set(obj.hAxes, 'Visible', 'off'); + end + + function t = getType(~) + t = 'image'; + end + + function s = toStruct(obj) + s = toStruct@DashboardWidget(obj); + if ~isempty(obj.File), s.file = obj.File; end + if ~isempty(obj.Caption), s.caption = obj.Caption; end + s.scaling = obj.Scaling; + if ~isempty(obj.ImageFcn) && isempty(obj.File) + s.source = struct('type', 'callback', ... + 'function', func2str(obj.ImageFcn)); + end + end + end + + methods (Static) + function obj = fromStruct(s) + obj = ImageWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'file'), obj.File = s.file; end + if isfield(s, 'caption'), obj.Caption = s.caption; end + if isfield(s, 'scaling'), obj.Scaling = s.scaling; end + end + end +end diff --git a/tests/suite/TestImageWidget.m b/tests/suite/TestImageWidget.m new file mode 100644 index 00000000..21602389 --- /dev/null +++ b/tests/suite/TestImageWidget.m @@ -0,0 +1,38 @@ +classdef TestImageWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = ImageWidget(); + testCase.verifyEqual(w.getType(), 'image'); + testCase.verifyEqual(w.Scaling, 'fit'); + end + + function testRenderWithImageFcn(testCase) + w = ImageWidget('Title', 'Test Image'); + w.ImageFcn = @() uint8(randi(255, 50, 50, 3)); + + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + hp = uipanel(fig, 'Position', [0 0 1 1]); + w.ParentTheme = DashboardTheme('dark'); + w.render(hp); + testCase.verifyNotEmpty(w.hPanel); + end + + function testToStruct(testCase) + w = ImageWidget('Title', 'Img'); + w.File = '/tmp/test.png'; + w.Caption = 'A test image'; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'image'); + testCase.verifyEqual(s.file, '/tmp/test.png'); + testCase.verifyEqual(s.caption, 'A test image'); + end + end +end From 78c96a1f5767a06785a43ec9a7315c8bbfa67afd Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:44:48 +0100 Subject: [PATCH 09/12] feat(dashboard): add MultiStatusWidget for multi-sensor status grid Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/Dashboard/MultiStatusWidget.m | 146 ++++++++++++++++++++++++++++ tests/suite/TestMultiStatusWidget.m | 27 +++++ 2 files changed, 173 insertions(+) create mode 100644 libs/Dashboard/MultiStatusWidget.m create mode 100644 tests/suite/TestMultiStatusWidget.m diff --git a/libs/Dashboard/MultiStatusWidget.m b/libs/Dashboard/MultiStatusWidget.m new file mode 100644 index 00000000..719ba3fe --- /dev/null +++ b/libs/Dashboard/MultiStatusWidget.m @@ -0,0 +1,146 @@ +classdef MultiStatusWidget < DashboardWidget + properties (Access = public) + Sensors = {} % Cell array of Sensor objects + Columns = [] % Grid columns (empty = auto) + ShowLabels = true + IconStyle = 'dot' % 'dot', 'square', 'icon' + end + + properties (SetAccess = private) + hAxes = [] + end + + methods + function obj = MultiStatusWidget(varargin) + obj = obj@DashboardWidget(varargin{:}); + if isequal(obj.Position, [1 1 6 2]) + obj.Position = [1 1 8 3]; + end + end + + function render(obj, parentPanel) + obj.hPanel = parentPanel; + theme = obj.getTheme(); + obj.hAxes = axes('Parent', parentPanel, ... + 'Units', 'normalized', ... + 'Position', [0.02 0.02 0.96 0.96], ... + 'Visible', 'off', ... + 'XLim', [0 1], 'YLim', [0 1]); + obj.refresh(); + end + + function refresh(obj) + if isempty(obj.hAxes) || ~ishandle(obj.hAxes) + return; + end + + n = numel(obj.Sensors); + if n == 0, return; end + + cols = obj.Columns; + if isempty(cols) + cols = ceil(sqrt(n)); + end + rows = ceil(n / cols); + + cla(obj.hAxes); + hold(obj.hAxes, 'on'); + + theme = obj.getTheme(); + okColor = theme.StatusOkColor; + warnColor = theme.StatusWarnColor; + alarmColor = theme.StatusAlarmColor; + + for i = 1:n + col = mod(i-1, cols); + row = floor((i-1) / cols); + + cx = (col + 0.5) / cols; + cy = 1 - (row + 0.5) / rows; + + % Determine color from sensor thresholds + sensor = obj.Sensors{i}; + color = okColor; + if ~isempty(sensor) && ~isempty(sensor.Y) + val = sensor.Y(end); + if ~isempty(sensor.ThresholdRules) + for k = 1:numel(sensor.ThresholdRules) + rule = sensor.ThresholdRules{k}; + if ~isempty(rule.Color) + if rule.IsUpper && val >= rule.Value + color = rule.Color; + elseif ~rule.IsUpper && val <= rule.Value + color = rule.Color; + end + end + end + end + end + + % Draw indicator + r = 0.3 / max(cols, rows); + if strcmp(obj.IconStyle, 'square') + rectangle(obj.hAxes, 'Position', [cx-r cy-r 2*r 2*r], ... + 'FaceColor', color, 'EdgeColor', 'none'); + else + theta = linspace(0, 2*pi, 30); + fill(obj.hAxes, cx + r*cos(theta), cy + r*sin(theta), ... + color, 'EdgeColor', 'none'); + end + + % Label + if obj.ShowLabels && ~isempty(sensor) + name = sensor.Name; + if isempty(name), name = sensor.Key; end + text(obj.hAxes, cx, cy - r - 0.02, name, ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 8, ... + 'Color', theme.AxisColor); + end + end + hold(obj.hAxes, 'off'); + end + + function t = getType(~) + t = 'multistatus'; + end + + function s = toStruct(obj) + % Fully override — does not use base Sensor property + s = struct(); + s.type = 'multistatus'; + s.title = obj.Title; + s.description = obj.Description; + s.position = struct('col', obj.Position(1), 'row', obj.Position(2), ... + 'width', obj.Position(3), 'height', obj.Position(4)); + if ~isempty(fieldnames(obj.ThemeOverride)) + s.themeOverride = obj.ThemeOverride; + end + s.columns = obj.Columns; + s.showLabels = obj.ShowLabels; + s.iconStyle = obj.IconStyle; + % Serialize sensor keys + keys = cell(1, numel(obj.Sensors)); + for i = 1:numel(obj.Sensors) + keys{i} = obj.Sensors{i}.Key; + end + s.sensors = keys; + end + end + + methods (Static) + function obj = fromStruct(s) + obj = MultiStatusWidget(); + if isfield(s, 'title'), obj.Title = s.title; end + if isfield(s, 'description'), obj.Description = s.description; end + if isfield(s, 'position') + obj.Position = [s.position.col, s.position.row, ... + s.position.width, s.position.height]; + end + if isfield(s, 'columns'), obj.Columns = s.columns; end + if isfield(s, 'showLabels'), obj.ShowLabels = s.showLabels; end + if isfield(s, 'iconStyle'), obj.IconStyle = s.iconStyle; end + % Sensor resolution happens via resolver in configToWidgets + end + end +end diff --git a/tests/suite/TestMultiStatusWidget.m b/tests/suite/TestMultiStatusWidget.m new file mode 100644 index 00000000..197c2e1b --- /dev/null +++ b/tests/suite/TestMultiStatusWidget.m @@ -0,0 +1,27 @@ +classdef TestMultiStatusWidget < matlab.unittest.TestCase + methods (TestClassSetup) + function addPaths(testCase) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Test) + function testDefaultConstruction(testCase) + w = MultiStatusWidget(); + testCase.verifyEqual(w.getType(), 'multistatus'); + testCase.verifyEqual(w.ShowLabels, true); + testCase.verifyEqual(w.IconStyle, 'dot'); + end + + function testToStruct(testCase) + w = MultiStatusWidget('Title', 'Status Grid'); + w.Columns = 4; + w.IconStyle = 'square'; + s = w.toStruct(); + testCase.verifyEqual(s.type, 'multistatus'); + testCase.verifyEqual(s.columns, 4); + testCase.verifyEqual(s.iconStyle, 'square'); + end + end +end From f228f496154ded7186652be0627b84a357de9bb1 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:46:48 +0100 Subject: [PATCH 10/12] feat(dashboard): register all 6 new widget types in engine, serializer, and bridge Co-Authored-By: Claude Opus 4.6 (1M context) --- bridge/web/js/widgets.js | 91 ++++++++++++++++++++++++++++ libs/Dashboard/DashboardEngine.m | 18 ++++++ libs/Dashboard/DashboardSerializer.m | 28 +++++++++ 3 files changed, 137 insertions(+) diff --git a/bridge/web/js/widgets.js b/bridge/web/js/widgets.js index e64691b4..da4dca67 100644 --- a/bridge/web/js/widgets.js +++ b/bridge/web/js/widgets.js @@ -18,6 +18,12 @@ var Widgets = (function () { case "timeline": return renderTimeline(config, bodyEl); case "rawaxes": return renderRawAxes(config, bodyEl); case "group": return renderGroup(config, bodyEl); + case "heatmap": return renderHeatmap(config, bodyEl); + case "barchart": return renderBarChart(config, bodyEl); + case "histogram":return renderHistogram(config, bodyEl); + case "scatter": return renderScatter(config, bodyEl); + case "image": return renderImage(config, bodyEl); + case "multistatus": return renderMultiStatus(config, bodyEl); default: bodyEl.textContent = "Unknown widget type: " + type; } @@ -322,6 +328,91 @@ var Widgets = (function () { }); container.appendChild(content); } + + /* --- Heatmap ------------------------------------------- */ + function renderHeatmap(cfg, el) { + el.innerHTML = '
' + + '
🟥
' + + '
Heatmap: ' + (cfg.title || '') + '
' + + '
'; + } + + /* --- Bar Chart ----------------------------------------- */ + function renderBarChart(cfg, el) { + el.innerHTML = '
' + + '
📊
' + + '
Bar Chart: ' + (cfg.title || '') + '
' + + '
'; + } + + /* --- Histogram ----------------------------------------- */ + function renderHistogram(cfg, el) { + el.innerHTML = '
' + + '
📈
' + + '
Histogram: ' + (cfg.title || '') + '
' + + '
'; + } + + /* --- Scatter ------------------------------------------- */ + function renderScatter(cfg, el) { + el.innerHTML = '
' + + '
' + + '
Scatter: ' + (cfg.title || '') + '
' + + '
'; + } + + /* --- Image --------------------------------------------- */ + function renderImage(cfg, el) { + if (cfg.file) { + var img = document.createElement("img"); + img.src = cfg.file; + img.alt = cfg.title || "Image"; + img.style.maxWidth = "100%"; + img.style.maxHeight = "100%"; + img.style.objectFit = "contain"; + el.appendChild(img); + } else { + el.innerHTML = '
' + + '
🖼
' + + '
Image: ' + (cfg.title || '') + '
' + + '
'; + } + } + + /* --- Multi-Status -------------------------------------- */ + function renderMultiStatus(cfg, el) { + var items = cfg.items || []; + if (items.length === 0) { + el.innerHTML = '
' + + '
Multi-Status: ' + (cfg.title || '') + '
' + + '
'; + return; + } + var grid = document.createElement("div"); + grid.style.display = "grid"; + grid.style.gridTemplateColumns = "repeat(auto-fill, minmax(80px, 1fr))"; + grid.style.gap = "6px"; + grid.style.padding = "8px"; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var cell = document.createElement("div"); + cell.style.display = "flex"; + cell.style.alignItems = "center"; + cell.style.gap = "4px"; + var dot = document.createElement("span"); + dot.style.width = "10px"; + dot.style.height = "10px"; + dot.style.borderRadius = "50%"; + dot.style.display = "inline-block"; + dot.style.backgroundColor = item.color || "#888"; + cell.appendChild(dot); + var lbl = document.createElement("span"); + lbl.style.fontSize = "0.8em"; + lbl.textContent = item.label || ""; + cell.appendChild(lbl); + grid.appendChild(cell); + } + el.appendChild(grid); } /* --- helpers ------------------------------------------- */ diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index ad5ea38f..847db2c8 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -92,6 +92,18 @@ function addWidget(obj, type, varargin) end case 'group' w = GroupWidget(varargin{:}); + case 'heatmap' + w = HeatmapWidget(varargin{:}); + case 'barchart' + w = BarChartWidget(varargin{:}); + case 'histogram' + w = HistogramWidget(varargin{:}); + case 'scatter' + w = ScatterWidget(varargin{:}); + case 'image' + w = ImageWidget(varargin{:}); + case 'multistatus' + w = MultiStatusWidget(varargin{:}); otherwise error('DashboardEngine:unknownType', ... 'Unknown widget type: %s', type); @@ -566,6 +578,12 @@ function onLiveTick(obj) 'timeline', 'Event timeline display (EventTimelineWidget)' 'rawaxes', 'Raw MATLAB axes for custom plotting (RawAxesWidget)' 'group', 'Widget container with panel/collapsible/tabbed modes (GroupWidget)' + 'heatmap', 'Heatmap color grid (HeatmapWidget)' + 'barchart', 'Bar chart for categories (BarChartWidget)' + 'histogram', 'Value distribution histogram (HistogramWidget)' + 'scatter', 'X vs Y scatter plot (ScatterWidget)' + 'image', 'Static image display (ImageWidget)' + 'multistatus', 'Multi-sensor status grid (MultiStatusWidget)' }; end diff --git a/libs/Dashboard/DashboardSerializer.m b/libs/Dashboard/DashboardSerializer.m index c948308c..79c792ce 100644 --- a/libs/Dashboard/DashboardSerializer.m +++ b/libs/Dashboard/DashboardSerializer.m @@ -115,6 +115,18 @@ function save(config, filepath) w = EventTimelineWidget.fromStruct(ws); case 'group' w = GroupWidget.fromStruct(ws); + case 'heatmap' + w = HeatmapWidget.fromStruct(ws); + case 'barchart' + w = BarChartWidget.fromStruct(ws); + case 'histogram' + w = HistogramWidget.fromStruct(ws); + case 'scatter' + w = ScatterWidget.fromStruct(ws); + case 'image' + w = ImageWidget.fromStruct(ws); + case 'multistatus' + w = MultiStatusWidget.fromStruct(ws); otherwise warning('DashboardSerializer:unknownType', ... 'Unknown widget type: %s — skipping', ws.type); @@ -231,6 +243,22 @@ function exportScript(config, filepath) line = [line, sprintf(', ...\n ''Mode'', ''%s''', ws.mode)]; end lines{end+1} = [line, ');']; + case 'heatmap' + lines{end+1} = sprintf('d.addWidget(''heatmap'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos); + case 'barchart' + lines{end+1} = sprintf('d.addWidget(''barchart'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos); + case 'histogram' + lines{end+1} = sprintf('d.addWidget(''histogram'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos); + case 'scatter' + lines{end+1} = sprintf('d.addWidget(''scatter'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos); + case 'image' + line = sprintf('d.addWidget(''image'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos); + if isfield(ws, 'file') && ~isempty(ws.file) + line = [line, sprintf(', ...\n ''File'', ''%s''', ws.file)]; + end + lines{end+1} = [line, ');']; + case 'multistatus' + lines{end+1} = sprintf('d.addWidget(''multistatus'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos); otherwise lines{end+1} = sprintf('d.addWidget(''%s'', ''Title'', ''%s'', ''Position'', %s);', ws.type, ws.title, pos); end From f48679a46632f48db6c39ccd250a4c974cd380d3 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:57:00 +0100 Subject: [PATCH 11/12] docs(dashboard): add example scripts for all new widget types Create standalone runnable example scripts for the 6 Phase B widgets (HeatmapWidget, BarChartWidget, HistogramWidget, ScatterWidget, ImageWidget, MultiStatusWidget) and a documented placeholder for the planned GroupWidget. Also extend example_dashboard_all_widgets.m to include all 6 new widget types in the all-in-one demo. Co-Authored-By: Claude Sonnet 4.6 --- examples/example_dashboard_all_widgets.m | 87 ++++++++++++ examples/example_widget_barchart.m | 85 +++++++++++ examples/example_widget_group.m | 146 +++++++++++++++++++ examples/example_widget_heatmap.m | 100 +++++++++++++ examples/example_widget_histogram.m | 103 ++++++++++++++ examples/example_widget_image.m | 79 +++++++++++ examples/example_widget_multistatus.m | 171 +++++++++++++++++++++++ examples/example_widget_scatter.m | 107 ++++++++++++++ 8 files changed, 878 insertions(+) create mode 100644 examples/example_widget_barchart.m create mode 100644 examples/example_widget_group.m create mode 100644 examples/example_widget_heatmap.m create mode 100644 examples/example_widget_histogram.m create mode 100644 examples/example_widget_image.m create mode 100644 examples/example_widget_multistatus.m create mode 100644 examples/example_widget_scatter.m diff --git a/examples/example_dashboard_all_widgets.m b/examples/example_dashboard_all_widgets.m index 7963876e..d064a2c6 100644 --- a/examples/example_dashboard_all_widgets.m +++ b/examples/example_dashboard_all_widgets.m @@ -6,6 +6,12 @@ % Number/Gauge/Status: driven by sensor data (latest value / threshold check) % Text/Table: static display (no interactivity needed) % Timeline: derived from threshold violations +% Heatmap: 2D colour grid (DataFcn returning matrix) +% BarChart: categorical bar chart (DataFcn returning struct) +% Histogram: sensor value distribution with optional normal fit +% Scatter: two-sensor X-vs-Y correlation plot +% Image: generated test pattern via ImageFcn +% MultiStatus: per-sensor status grid (dot indicators) % % Usage: % example_dashboard_all_widgets @@ -247,11 +253,92 @@ 'Position', [1 19 24 3], ... 'Events', events); +% --- Row 22-27: Phase B widgets — Heatmap, BarChart, Histogram --- + +% Heatmap: temperature binned by hour-of-day (24 cols) vs machine mode (3 rows) +% Pre-compute mode at every sample to avoid calling valueAt N*24*3 times. +tMode = zeros(1, N); +for k = 1:N + tMode(k) = scMode.valueAt(t(k)); +end +hourBins = zeros(3, 24); +for h = 0:23 + hourIdx = (floor(t / 3600) == h); + for m = 0:2 + mIdx = hourIdx & (tMode == m); + if any(mIdx) + hourBins(m+1, h+1) = mean(temp(mIdx)); + end + end +end +hourXLabels = cell(1, 24); +for h = 0:23 + hourXLabels{h+1} = sprintf('%dh', h); +end +d.addWidget('heatmap', 'Title', 'Temp by Hour & Machine Mode', ... + 'Position', [1 22 14 6], ... + 'DataFcn', @() hourBins, ... + 'Colormap', 'jet', ... + 'XLabels', hourXLabels, ... + 'YLabels', {'Idle','Running','Maint'}); + +% BarChart: alarm counts by sensor tag +alarmCounts = struct( ... + 'categories', {{'T-401','P-201','F-301'}}, ... + 'values', [sTemp.countViolations(), sPress.countViolations(), sFlow.countViolations()]); +d.addWidget('barchart', 'Title', 'Violation Count by Sensor', ... + 'Position', [15 22 10 6], ... + 'DataFcn', @() alarmCounts, ... + 'Orientation', 'vertical'); + +% --- Row 28-33: Histogram, Scatter, Image, MultiStatus --- + +% Histogram: temperature distribution with normal fit +d.addWidget('histogram', 'Title', 'Temperature Distribution', ... + 'Position', [1 28 8 6], ... + 'Sensor', sTemp, ... + 'ShowNormalFit', true, ... + 'NumBins', 40); + +% Scatter: temperature vs pressure, color-coded by flow rate +d.addWidget('scatter', 'Title', 'Temp vs Pressure (color=Flow)', ... + 'Position', [9 28 8 6], ... + 'SensorX', sTemp, ... + 'SensorY', sPress, ... + 'SensorColor', sFlow, ... + 'MarkerSize', 4, ... + 'Colormap', 'parula'); + +% Image: procedural test pattern (no external file dependency) +d.addWidget('image', 'Title', 'Process Diagram', ... + 'Position', [17 28 8 6], ... + 'ImageFcn', @() makePeaksThumb(), ... + 'Caption', 'Elevation map (peaks function)', ... + 'Scaling', 'fit'); + +% MultiStatus: all three process sensors at a glance +d.addWidget('multistatus', 'Title', 'Sensor Status', ... + 'Position', [1 34 24 3], ... + 'Sensors', {sTemp, sPress, sFlow}, ... + 'Columns', 3, ... + 'IconStyle', 'dot', ... + 'ShowLabels', true); + %% Render d.render(); fprintf('Dashboard rendered with %d widgets.\n', numel(d.Widgets)); fprintf('Sensors: T-401 (%d violations), P-201 (%d violations), F-301 (%d violations)\n', ... sTemp.countViolations(), sPress.countViolations(), sFlow.countViolations()); +fprintf('Phase B widgets added: heatmap, barchart, histogram, scatter, image, multistatus.\n'); fprintf('Click "Edit" to enter GUI builder mode.\n'); fprintf('Click "Save" to export as JSON, "Export" to generate .m script.\n'); + +%% ---- Helper function ---- +function img = makePeaksThumb() +% Return a grayscale uint8 image derived from peaks(64). +Z = peaks(64); +Z = Z - min(Z(:)); +Z = Z ./ max(Z(:)); +img = uint8(round(Z * 255)); +end diff --git a/examples/example_widget_barchart.m b/examples/example_widget_barchart.m new file mode 100644 index 00000000..e516d41c --- /dev/null +++ b/examples/example_widget_barchart.m @@ -0,0 +1,85 @@ +%% BarChartWidget — All Configurations Demo +% Demonstrates BarChartWidget with vertical and horizontal orientations, +% DataFcn data sources, and a sensor-bound mode. +% +% BarChartWidget Properties: +% DataFcn — function_handle returning a struct with fields: +% categories — cell array of category labels +% values — numeric vector of bar heights +% or returning a plain numeric vector (no labels). +% Sensor — Sensor object; Y vector used directly as bar values. +% Orientation — 'vertical' (default) or 'horizontal'. +% Stacked — stacked bars when multiple value columns (default false). +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_barchart + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Define data sources + +% --- Production output per shift (vertical bar chart) --- +shiftData = struct( ... + 'categories', {{'Shift A', 'Shift B', 'Shift C', 'Shift D'}}, ... + 'values', [412, 387, 455, 398]); + +% --- Alarm counts by sensor (horizontal — long category labels) --- +alarmData = struct( ... + 'categories', {{'Temperature T-401', 'Pressure P-201', 'Flow F-301', ... + 'Level L-102', 'Vibration V-501'}}, ... + 'values', [8, 3, 12, 1, 5]); + +% --- Weekly throughput — plain numeric, no labels --- +weeklyValues = [1820 1975 1843 2010 1965 1750 1410]; + +% --- Sensor-bound: hourly flow averages (8 buckets) --- +rng(42); +N = 5000; +t = linspace(0, 28800, N); % 8 hours in seconds +sFlow = Sensor('F-301', 'Name', 'Flow Rate'); +sFlow.Units = 'L/min'; +sFlow.X = t; +sFlow.Y = max(0, 120 + 20*sin(2*pi*t/3600) + randn(1,N)*5); +sFlow.addThresholdRule(struct(), 140, ... + 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sFlow.resolve(); + +%% 2. Build dashboard +d = DashboardEngine('Bar Chart Widget Demo'); +d.Theme = 'light'; + +% Row 1-5: Vertical — production by shift +d.addWidget('barchart', 'Title', 'Production by Shift', ... + 'Position', [1 1 12 5], ... + 'DataFcn', @() shiftData, ... + 'Orientation', 'vertical'); + +% Row 1-5: Horizontal — alarm counts by sensor tag +d.addWidget('barchart', 'Title', 'Alarm Count by Sensor', ... + 'Position', [13 1 12 5], ... + 'DataFcn', @() alarmData, ... + 'Orientation', 'horizontal'); + +% Row 6-10: Vertical, no labels — plain numeric vector +d.addWidget('barchart', 'Title', 'Weekly Throughput (Mon-Sun)', ... + 'Position', [1 6 12 5], ... + 'DataFcn', @() weeklyValues, ... + 'Orientation', 'vertical'); + +% Row 6-10: Sensor-bound (uses Sensor.Y values directly as bar data) +d.addWidget('barchart', 'Title', 'Flow Distribution (sensor)', ... + 'Position', [13 6 12 5], ... + 'Sensor', sFlow, ... + 'Orientation', 'vertical'); + +%% 3. Render +d.render(); +fprintf('Dashboard rendered with %d bar chart widgets.\n', numel(d.Widgets)); +fprintf('Flow sensor %s: latest %.1f %s\n', ... + sFlow.Key, sFlow.Y(end), sFlow.Units); diff --git a/examples/example_widget_group.m b/examples/example_widget_group.m new file mode 100644 index 00000000..29d49d2b --- /dev/null +++ b/examples/example_widget_group.m @@ -0,0 +1,146 @@ +%% GroupWidget — All Modes Demo +% Demonstrates GroupWidget as a container for child widgets, covering all +% three grouping modes: panel, collapsible, and tabbed. +% +% NOTE: GroupWidget is defined in the Phase A implementation plan +% (docs/superpowers/plans/2026-03-18-dashboard-groupwidget-phase-a.md). +% This example script will run correctly once GroupWidget is implemented +% and registered in DashboardEngine. Until then it documents the +% intended API; uncommenting is the only change needed after Phase A lands. +% +% GroupWidget Properties: +% Mode — 'panel' (default) | 'collapsible' | 'tabbed'. +% Label — header title string shown in the group header bar. +% Children — cell array of DashboardWidget (panel / collapsible modes). +% Tabs — cell array of structs with fields 'name' and 'widgets' +% (tabbed mode only). +% ActiveTab — name of the initially visible tab (tabbed mode). +% Collapsed — initial collapsed state (collapsible mode, default false). +% ChildAutoFlow — auto-arrange children left-to-right (default true). +% ChildColumns — column count of the child sub-grid (default 24). +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_group + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Create sensors +rng(42); +N = 5000; +t = linspace(0, 86400, N); % 24 hours + +sTemp = Sensor('T-401', 'Name', 'Temperature'); +sTemp.Units = [char(176) 'F']; +sTemp.X = t; +sTemp.Y = 72 + 4*sin(2*pi*t/3600) + randn(1,N)*1.2; +sTemp.Y(end) = 79; % near warning level +sTemp.addThresholdRule(struct(), 78, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sTemp.addThresholdRule(struct(), 85, ... + 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sTemp.resolve(); + +sPress = Sensor('P-201', 'Name', 'Pressure'); +sPress.Units = 'psi'; +sPress.X = t; +sPress.Y = 55 + 8*sin(2*pi*t/7200) + randn(1,N)*1.5; +sPress.addThresholdRule(struct(), 68, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sPress.resolve(); + +sFlow = Sensor('F-301', 'Name', 'Flow Rate'); +sFlow.Units = 'L/min'; +sFlow.X = t; +sFlow.Y = max(0, 120 + 10*sin(2*pi*t/1800) + randn(1,N)*4); +sFlow.resolve(); + +sVib = Sensor('V-501', 'Name', 'Vibration RMS'); +sVib.Units = 'mm/s'; +sVib.X = t; +sVib.Y = max(0.1, 1.5 + 0.4*sin(2*pi*t/5400) + randn(1,N)*0.2); +sVib.resolve(); + +%% 2. Build dashboard +d = DashboardEngine('Group Widget Demo'); +d.Theme = 'light'; + +%% --- Mode 1: Panel group (default) --- +% A panel group acts as a titled container. Children auto-flow left to +% right inside the group boundaries. Equivalent to placing widgets in a +% named section of the dashboard. +% +% g1 = GroupWidget('Label', 'Motor Health', 'Mode', 'panel', ... +% 'Position', [1 1 24 4]); +% g1.addChild(NumberWidget('Sensor', sTemp)); +% g1.addChild(NumberWidget('Sensor', sPress)); +% g1.addChild(GaugeWidget('Sensor', sTemp, 'Style', 'arc')); +% g1.addChild(GaugeWidget('Sensor', sPress, 'Style', 'arc')); +% d.addWidget('group', 'Label', 'Motor Health', 'Mode', 'panel', ... +% 'Position', [1 1 24 4], ... +% 'Children', {NumberWidget('Sensor', sTemp), ... +% NumberWidget('Sensor', sPress), ... +% GaugeWidget('Sensor', sTemp, 'Style', 'arc'), ... +% GaugeWidget('Sensor', sPress, 'Style', 'arc')}); + +%% --- Mode 2: Collapsible group --- +% A collapsible group renders a clickable header that hides/shows children. +% Collapse() reduces Position(4) to 1 grid row; DashboardLayout.reflow() +% compacts widgets below it automatically. +% +% d.addWidget('group', 'Label', 'Vibration Details', 'Mode', 'collapsible', ... +% 'Position', [1 5 24 6], ... +% 'Collapsed', false, ... +% 'Children', {FastSenseWidget('Sensor', sVib)}); + +%% --- Mode 3: Tabbed group --- +% A tabbed group shows one tab at a time. Each tab gets its own named +% panel. Tab buttons appear in the header bar. ActiveTab sets which tab +% is visible on first render. +% +% tab1 = struct('name', 'Overview', 'widgets', {{ ... +% NumberWidget('Sensor', sTemp), ... +% NumberWidget('Sensor', sPress), ... +% StatusWidget('Sensor', sTemp) ... +% }}); +% tab2 = struct('name', 'Trends', 'widgets', {{ ... +% FastSenseWidget('Sensor', sTemp), ... +% FastSenseWidget('Sensor', sFlow) ... +% }}); +% d.addWidget('group', 'Label', 'Process Monitor', 'Mode', 'tabbed', ... +% 'Position', [1 11 24 8], ... +% 'Tabs', {tab1, tab2}, ... +% 'ActiveTab', 'Overview'); + +%% Fallback: render standalone widgets that mirror GroupWidget children +% This section produces a working dashboard today and will be replaced once +% GroupWidget lands. + +% Panel group equivalent — KPI row + gauge row +d.addWidget('number', 'Position', [1 1 6 2], 'Sensor', sTemp); +d.addWidget('number', 'Position', [7 1 6 2], 'Sensor', sPress); +d.addWidget('gauge', 'Position', [13 1 6 4], 'Sensor', sTemp, 'Style', 'arc'); +d.addWidget('gauge', 'Position', [19 1 6 4], 'Sensor', sPress, 'Style', 'arc'); + +% Collapsible group equivalent — vibration detail +d.addWidget('fastsense', 'Position', [1 5 24 6], 'Sensor', sVib); + +% Tabbed group equivalent — overview tab (visible) + trends tab (hidden) +d.addWidget('number', 'Position', [1 11 6 2], 'Sensor', sTemp); +d.addWidget('number', 'Position', [7 11 6 2], 'Sensor', sPress); +d.addWidget('status', 'Position', [13 11 5 2], 'Sensor', sTemp); +d.addWidget('fastsense', 'Position', [1 13 12 6], 'Sensor', sTemp); +d.addWidget('fastsense', 'Position', [13 13 12 6], 'Sensor', sFlow); + +%% 3. Render +d.render(); + +fprintf('Dashboard rendered with %d widgets.\n', numel(d.Widgets)); +fprintf('GroupWidget Phase A required for group/collapsible/tabbed container modes.\n'); +fprintf('See: docs/superpowers/plans/2026-03-18-dashboard-groupwidget-phase-a.md\n'); diff --git a/examples/example_widget_heatmap.m b/examples/example_widget_heatmap.m new file mode 100644 index 00000000..c4732d16 --- /dev/null +++ b/examples/example_widget_heatmap.m @@ -0,0 +1,100 @@ +%% HeatmapWidget — All Configurations Demo +% Demonstrates HeatmapWidget in three configurations: +% 1. Temperature grid: hour-of-day vs day-of-week (DataFcn + 'jet' colormap) +% 2. Correlation matrix: six process variables (custom labels, 'hot' colormap) +% 3. Sensor-bound: wraps a sensor Y vector as a 2D matrix +% +% HeatmapWidget Properties: +% DataFcn — function_handle returning a 2D matrix (polled on refresh). +% Sensor — Sensor object; Y vector reshaped to 2D. +% Colormap — colormap name or Nx3 matrix (default 'parula'). +% ShowColorbar — show/hide colorbar (default true). +% XLabels — cell array of column-axis tick labels. +% YLabels — cell array of row-axis tick labels. +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_heatmap + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Build data sources + +% --- Temperature grid: 7 rows (Mon-Sun) x 24 cols (hour 0-23) --- +rng(42); +dayBase = [68 72 74 72 70 66 64]; % Mon-Sun baseline in degF +hourShape = sin(pi * (0:23) / 23); % gentle bell across the day +tempGrid = bsxfun(@plus, dayBase', bsxfun(@times, 6 * hourShape, ones(7,1))); +tempGrid = tempGrid + 2*randn(7, 24); % process noise + +hourLabels = {'0h','1h','2h','3h','4h','5h','6h','7h','8h','9h', ... + '10h','11h','12h','13h','14h','15h','16h','17h','18h','19h', ... + '20h','21h','22h','23h'}; +dayLabels = {'Mon','Tue','Wed','Thu','Fri','Sat','Sun'}; + +% --- Correlation matrix (6 variables) --- +vars = {'Temp','Press','Flow','RPM','Vibr','Power'}; +nVars = numel(vars); +corrMat = eye(nVars); +corrMat(1,2) = 0.82; corrMat(2,1) = 0.82; +corrMat(1,3) = -0.55; corrMat(3,1) = -0.55; +corrMat(2,3) = -0.40; corrMat(3,2) = -0.40; +corrMat(1,4) = 0.30; corrMat(4,1) = 0.30; +corrMat(2,4) = 0.65; corrMat(4,2) = 0.65; +corrMat(3,4) = 0.71; corrMat(4,3) = 0.71; +corrMat(1,5) = 0.20; corrMat(5,1) = 0.20; +corrMat(2,5) = 0.45; corrMat(5,2) = 0.45; +corrMat(4,5) = 0.88; corrMat(5,4) = 0.88; +corrMat(4,6) = 0.95; corrMat(6,4) = 0.95; +corrMat(1,6) = 0.28; corrMat(6,1) = 0.28; +corrMat(2,6) = 0.60; corrMat(6,2) = 0.60; +corrMat(3,6) = -0.38; corrMat(6,3) = -0.38; +corrMat(5,6) = 0.90; corrMat(6,5) = 0.90; + +% --- Sensor-bound (vibration RMS over time, reshaped 8x16) --- +N = 5000; +t = linspace(0, 86400, N); +sVib = Sensor('V-501', 'Name', 'Vibration RMS'); +sVib.Units = 'mm/s'; +sVib.X = t; +sVib.Y = 1.5 + 0.8*sin(2*pi*t/7200) + randn(1,N)*0.15; +sVib.Y(t > 43200 & t < 46800) = sVib.Y(t > 43200 & t < 46800) + 1.2; +sVib.resolve(); + +%% 2. Build dashboard +d = DashboardEngine('Heatmap Widget Demo'); +d.Theme = 'light'; + +% Row 1-6: Temperature grid — hour-of-day vs day-of-week, 'jet' colormap +d.addWidget('heatmap', 'Title', 'Temperature by Hour & Day', ... + 'Position', [1 1 14 6], ... + 'DataFcn', @() tempGrid, ... + 'Colormap', 'jet', ... + 'ShowColorbar', true, ... + 'XLabels', hourLabels, ... + 'YLabels', dayLabels); + +% Row 1-6: Correlation matrix, 'hot' colormap, no colorbar +d.addWidget('heatmap', 'Title', 'Process Variable Correlations', ... + 'Position', [15 1 10 6], ... + 'DataFcn', @() corrMat, ... + 'Colormap', 'hot', ... + 'ShowColorbar', false, ... + 'XLabels', vars, ... + 'YLabels', vars); + +% Row 7-12: Sensor-bound — vibration data reshaped into 2D +d.addWidget('heatmap', 'Title', 'Vibration RMS Pattern', ... + 'Position', [1 7 14 6], ... + 'Sensor', sVib, ... + 'Colormap', 'parula'); + +%% 3. Render +d.render(); +fprintf('Dashboard rendered with %d heatmap widgets.\n', numel(d.Widgets)); +fprintf('Sensor %s: %d points, range [%.2f, %.2f] %s\n', ... + sVib.Key, numel(sVib.Y), min(sVib.Y), max(sVib.Y), sVib.Units); diff --git a/examples/example_widget_histogram.m b/examples/example_widget_histogram.m new file mode 100644 index 00000000..56128eef --- /dev/null +++ b/examples/example_widget_histogram.m @@ -0,0 +1,103 @@ +%% HistogramWidget — All Configurations Demo +% Demonstrates HistogramWidget with sensor-bound data, normal curve overlay, +% custom bin counts, and a DataFcn source. +% +% HistogramWidget Properties: +% Sensor — Sensor object; Y values are binned on refresh. +% DataFcn — function_handle returning a numeric vector. +% NumBins — number of bins (default auto = max(10, sqrt(N))). +% ShowNormalFit — overlay a scaled normal distribution curve (default false). +% EdgeColor — bin edge RGB color; [] uses theme default. +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_histogram + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Create sensors and data + +rng(42); +N = 5000; +t = linspace(0, 86400, N); % 24 hours in seconds + +% Temperature — nearly normal distribution +sTemp = Sensor('T-401', 'Name', 'Temperature'); +sTemp.Units = [char(176) 'F']; +sTemp.X = t; +sTemp.Y = 72 + 4*sin(2*pi*t/3600) + randn(1,N)*1.5; +sTemp.addThresholdRule(struct(), 78, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sTemp.addThresholdRule(struct(), 85, ... + 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sTemp.resolve(); + +% Pressure — bimodal (two machine modes) +sPress = Sensor('P-201', 'Name', 'Pressure'); +sPress.Units = 'psi'; +sPress.X = t; +modeA = t < 43200; % first 12 hours: lower pressure +modeB = t >= 43200; % second 12 hours: higher pressure +sPress.Y = zeros(1, N); +sPress.Y(modeA) = 30 + randn(1, sum(modeA))*3; +sPress.Y(modeB) = 55 + randn(1, sum(modeB))*4; +sPress.addThresholdRule(struct(), 65, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sPress.resolve(); + +% Vibration — log-normal (positively skewed) +sVib = Sensor('V-501', 'Name', 'Vibration RMS'); +sVib.Units = 'mm/s'; +sVib.X = t; +sVib.Y = max(0.1, exp(0.5 + 0.4*randn(1,N))); % log-normal +sVib.resolve(); + +% Custom DataFcn — batch cycle times (gamma-like) +cycleTimes = 28 + 15*abs(randn(1, 800)) + 5*randn(1,800); + +%% 2. Build dashboard +d = DashboardEngine('Histogram Widget Demo'); +d.Theme = 'light'; + +% Row 1-6: Temperature — sensor-bound, auto bins, with normal fit +d.addWidget('histogram', 'Title', 'Temperature Distribution', ... + 'Position', [1 1 12 6], ... + 'Sensor', sTemp, ... + 'ShowNormalFit', true); + +% Row 1-6: Pressure — sensor-bound, custom bin count (30), no fit +d.addWidget('histogram', 'Title', 'Pressure Distribution (30 bins)', ... + 'Position', [13 1 12 6], ... + 'Sensor', sPress, ... + 'NumBins', 30, ... + 'ShowNormalFit', false); + +% Row 7-12: Vibration — sensor-bound, auto bins, with normal fit (skew visible) +d.addWidget('histogram', 'Title', 'Vibration RMS — Log-Normal Shape', ... + 'Position', [1 7 12 6], ... + 'Sensor', sVib, ... + 'ShowNormalFit', true); + +% Row 7-12: Cycle times — DataFcn, custom bins, no fit +d.addWidget('histogram', 'Title', 'Batch Cycle Times', ... + 'Position', [13 7 12 6], ... + 'DataFcn', @() cycleTimes, ... + 'NumBins', 40, ... + 'ShowNormalFit', false); + +%% 3. Render +d.render(); +fprintf('Dashboard rendered with %d histogram widgets.\n', numel(d.Widgets)); +fprintf('Temperature: mean=%.1f std=%.1f (N=%d)\n', ... + mean(sTemp.Y), std(sTemp.Y), numel(sTemp.Y)); +fprintf('Pressure: mean=%.1f std=%.1f (bimodal)\n', ... + mean(sPress.Y), std(sPress.Y)); +fprintf('Vibration: mean=%.2f std=%.2f (log-normal)\n', ... + mean(sVib.Y), std(sVib.Y)); diff --git a/examples/example_widget_image.m b/examples/example_widget_image.m new file mode 100644 index 00000000..65c1bab3 --- /dev/null +++ b/examples/example_widget_image.m @@ -0,0 +1,79 @@ +%% ImageWidget — All Configurations Demo +% Demonstrates ImageWidget with procedurally generated images (no external +% files required), caption text, and the stretch scaling mode. +% +% ImageWidget Properties: +% ImageFcn — function_handle returning an image matrix: +% MxN — grayscale (intensity values as uint8 or double) +% MxNx3 — RGB (uint8 [0,255] or double [0,1]) +% Polled on refresh; no external file dependency. +% File — path to image file (PNG, JPG); existence validated at render. +% Caption — string displayed below the image. +% Scaling — 'fit' (default), 'fill', or 'stretch'. +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_image + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Build images procedurally (no imread / no file I/O) + +% --- Topographic surface — peaks(64) → grayscale uint8 --- +Z = peaks(64); +Z = Z - min(Z(:)); +Z = Z ./ max(Z(:)); +peaksImg = uint8(round(Z * 255)); % 64x64 grayscale + +% --- False-colour thermal map — sin/cos gradient → RGB --- +[X, Y] = meshgrid(linspace(0, 2*pi, 128), linspace(0, 2*pi, 128)); +R = uint8(round((0.5 + 0.5*sin(X)) * 255)); +G = uint8(round((0.5 + 0.5*cos(Y)) * 255)); +B = uint8(round((0.5 + 0.5*sin(X+Y)) * 255)); +thermalImg = cat(3, R, G, B); % 128x128x3 RGB + +% --- Checkerboard calibration pattern --- +sz = 128; sqSize = 16; +nSq = sz / sqSize; +tileRow = repmat([0 1], 1, ceil(nSq/2)); +tileRow = tileRow(1:nSq); +tileCol = tileRow; +block = xor(repmat(tileRow, nSq, 1), repmat(tileCol(:), 1, nSq)); +checkerImg = uint8(kron(block, ones(sqSize, sqSize)) * 255); % 128x128 + +%% 2. Build dashboard +d = DashboardEngine('Image Widget Demo'); +d.Theme = 'light'; + +% Row 1-8: Topographic surface (grayscale), caption visible +d.addWidget('image', 'Title', 'Terrain Elevation Map', ... + 'Position', [1 1 8 8], ... + 'ImageFcn', @() peaksImg, ... + 'Caption', 'Generated from peaks(64)', ... + 'Scaling', 'fit'); + +% Row 1-8: False-colour thermal map (RGB), no caption +d.addWidget('image', 'Title', 'False-Colour Thermal Map', ... + 'Position', [9 1 8 8], ... + 'ImageFcn', @() thermalImg, ... + 'Scaling', 'fit'); + +% Row 1-8: Checkerboard — tests pixel rendering, stretch scaling +d.addWidget('image', 'Title', 'Calibration Pattern', ... + 'Position', [17 1 8 8], ... + 'ImageFcn', @() checkerImg, ... + 'Caption', '128x128, 16-px squares', ... + 'Scaling', 'stretch'); + +%% 3. Render +d.render(); +fprintf('Dashboard rendered with %d image widgets.\n', numel(d.Widgets)); +fprintf('Images: grayscale %dx%d, RGB %dx%dx3, grayscale %dx%d (checker)\n', ... + size(peaksImg, 1), size(peaksImg, 2), ... + size(thermalImg, 1), size(thermalImg, 2), size(thermalImg, 3), ... + size(checkerImg, 1), size(checkerImg, 2)); +fprintf('All images generated procedurally — no external files required.\n'); diff --git a/examples/example_widget_multistatus.m b/examples/example_widget_multistatus.m new file mode 100644 index 00000000..408db95d --- /dev/null +++ b/examples/example_widget_multistatus.m @@ -0,0 +1,171 @@ +%% MultiStatusWidget — All Configurations Demo +% Demonstrates MultiStatusWidget with 8 sensors covering all three status +% zones (ok / warning / alarm) and compares 'dot' vs 'square' icon styles. +% +% MultiStatusWidget Properties: +% Sensors — cell array of Sensor objects, each with ThresholdRules. +% Status colour is derived from the worst active threshold +% violation at Y(end). +% Columns — number of columns in the indicator grid (default auto). +% ShowLabels — show sensor Name below each indicator (default true). +% IconStyle — 'dot' (default) | 'square'. +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Status derivation: +% - No threshold violated → green (ok) +% - At least one Warning rule violated → yellow (warning) +% - At least one Alarm rule violated → red (alarm) +% +% Usage: +% example_widget_multistatus + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Create 8 sensors in different status states + +rng(42); +N = 1000; +t = linspace(0, 3600, N); % 1 hour + +% --- Green (ok) sensors: tail stays within normal range --- + +% Temperature — ok (tail = 74, warn @ 80, alarm @ 90) +sTemp = Sensor('T-401', 'Name', 'Temperature', 'Units', [char(176) 'F']); +sTemp.X = t; +sTemp.Y = 72 + 1.5*randn(1,N); +sTemp.Y(end-50:end) = 74; +sTemp.addThresholdRule(struct(), 80, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sTemp.addThresholdRule(struct(), 90, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sTemp.resolve(); + +% Flow Rate — ok (tail = 115, lo warn @ 90, hi alarm @ 160) +sFlow = Sensor('F-301', 'Name', 'Flow Rate', 'Units', 'L/min'); +sFlow.X = t; +sFlow.Y = 120 + 3*randn(1,N); +sFlow.Y(end-50:end) = 115; +sFlow.addThresholdRule(struct(), 90, 'Direction', 'lower', 'Label', 'Lo Warn', ... + 'Color', [0.2 0.6 1], 'LineStyle', '--'); +sFlow.addThresholdRule(struct(), 160, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sFlow.resolve(); + +% Tank Level — ok (tail = 52, lo warn @ 15, hi warn @ 85, hi alarm @ 95) +sLevel = Sensor('L-102', 'Name', 'Tank Level', 'Units', '%'); +sLevel.X = t; +sLevel.Y = 50 + 5*randn(1,N); +sLevel.Y(end-50:end) = 52; +sLevel.addThresholdRule(struct(), 15, 'Direction', 'lower', 'Label', 'Lo Warn', ... + 'Color', [0.2 0.6 1], 'LineStyle', '--'); +sLevel.addThresholdRule(struct(), 85, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sLevel.addThresholdRule(struct(), 95, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sLevel.resolve(); + +% --- Yellow (warning) sensors: tail above warn but below alarm --- + +% Pressure — warning (tail = 67, warn @ 65, alarm @ 75) +sPress = Sensor('P-201', 'Name', 'Pressure', 'Units', 'psi'); +sPress.X = t; +sPress.Y = 45 + 3*randn(1,N); +sPress.Y(end-50:end) = 67; +sPress.addThresholdRule(struct(), 65, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sPress.addThresholdRule(struct(), 75, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sPress.resolve(); + +% Humidity — warning (tail = 82, warn @ 80, alarm @ 95) +sHumid = Sensor('H-601', 'Name', 'Humidity', 'Units', '%RH'); +sHumid.X = t; +sHumid.Y = 55 + 4*randn(1,N); +sHumid.Y(end-50:end) = 82; +sHumid.addThresholdRule(struct(), 80, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sHumid.addThresholdRule(struct(), 95, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sHumid.resolve(); + +% --- Red (alarm) sensors: tail above alarm threshold --- + +% Motor Current — alarm (tail = 16, warn @ 12, alarm @ 15) +sCurrent = Sensor('I-701', 'Name', 'Motor Current', 'Units', 'A'); +sCurrent.X = t; +sCurrent.Y = 8.5 + 0.5*randn(1,N); +sCurrent.Y(end-50:end) = 16; +sCurrent.addThresholdRule(struct(), 12, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sCurrent.addThresholdRule(struct(), 15, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sCurrent.resolve(); + +% Vibration RMS — alarm (tail = 4.5, warn @ 3, alarm @ 4) +sVib = Sensor('V-501', 'Name', 'Vibration RMS', 'Units', 'mm/s'); +sVib.X = t; +sVib.Y = max(0.1, 1.2 + 0.2*randn(1,N)); +sVib.Y(end-50:end) = 4.5; +sVib.addThresholdRule(struct(), 3, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sVib.addThresholdRule(struct(), 4, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sVib.resolve(); + +% CO Level — alarm (tail = 28, warn @ 15, alarm @ 25) +sCO = Sensor('GAS-1', 'Name', 'CO Level', 'Units', 'ppm'); +sCO.X = t; +sCO.Y = 5 + 2*randn(1,N); +sCO.Y(end-50:end) = 28; +sCO.addThresholdRule(struct(), 15, 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sCO.addThresholdRule(struct(), 25, 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sCO.resolve(); + +allSensors = {sTemp, sFlow, sLevel, sPress, sHumid, sCurrent, sVib, sCO}; + +%% 2. Build dashboard +d = DashboardEngine('Multi-Status Widget Demo'); +d.Theme = 'light'; + +% Row 1-5: dot style (default) — 8 sensors, 4-column grid +d.addWidget('multistatus', 'Title', 'Plant Status Overview — Dot Style', ... + 'Position', [1 1 12 5], ... + 'Sensors', allSensors, ... + 'Columns', 4, ... + 'IconStyle', 'dot', ... + 'ShowLabels', true); + +% Row 1-5: square style — same 8 sensors, 4-column grid +d.addWidget('multistatus', 'Title', 'Plant Status Overview — Square Style', ... + 'Position', [13 1 12 5], ... + 'Sensors', allSensors, ... + 'Columns', 4, ... + 'IconStyle', 'square', ... + 'ShowLabels', true); + +% Row 6-8: compact dot — 8 sensors, 8-column grid, no labels +d.addWidget('multistatus', 'Title', 'Compact Status (no labels)', ... + 'Position', [1 6 24 3], ... + 'Sensors', allSensors, ... + 'Columns', 8, ... + 'IconStyle', 'dot', ... + 'ShowLabels', false); + +% Row 9-14: FastSense context — one ok sensor, one alarm sensor +d.addWidget('fastsense', 'Position', [1 9 12 6], 'Sensor', sTemp); +d.addWidget('fastsense', 'Position', [13 9 12 6], 'Sensor', sCurrent); + +%% 3. Render +d.render(); + +fprintf('Dashboard rendered with %d widgets.\n', numel(d.Widgets)); +fprintf('Status summary (8 sensors):\n'); +fprintf(' ok: T-401 (Temperature), F-301 (Flow Rate), L-102 (Tank Level)\n'); +fprintf(' warning: P-201 (Pressure), H-601 (Humidity)\n'); +fprintf(' alarm: I-701 (Motor Current), V-501 (Vibration), GAS-1 (CO Level)\n'); diff --git a/examples/example_widget_scatter.m b/examples/example_widget_scatter.m new file mode 100644 index 00000000..4973bd69 --- /dev/null +++ b/examples/example_widget_scatter.m @@ -0,0 +1,107 @@ +%% ScatterWidget — All Configurations Demo +% Demonstrates ScatterWidget with two sensors (X vs Y), an optional +% color-coding third sensor, and customised marker size. +% +% ScatterWidget Properties: +% SensorX — Sensor object for the X axis. +% SensorY — Sensor object for the Y axis. +% SensorColor — Optional Sensor object; values drive point color (colormap). +% MarkerSize — scatter marker size in points (default 6). +% Colormap — colormap name or Nx3 matrix used with SensorColor (default 'parula'). +% Title — widget title. +% Position — [col row width height] on the 24-column grid. +% +% Usage: +% example_widget_scatter + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Create sensors + +rng(42); +N = 3000; +t = linspace(0, 86400, N); % 24 hours in seconds + +% Temperature and Pressure — positively correlated (higher temp = higher press) +sTemp = Sensor('T-401', 'Name', 'Temperature'); +sTemp.Units = [char(176) 'F']; +sTemp.X = t; +sTemp.Y = 72 + 6*sin(2*pi*t/7200) + randn(1,N)*1.5; +sTemp.addThresholdRule(struct(), 82, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sTemp.resolve(); + +sPress = Sensor('P-201', 'Name', 'Pressure'); +sPress.Units = 'psi'; +sPress.X = t; +% Deliberately correlated with temperature +sPress.Y = 40 + 0.8*(sTemp.Y - 72) + randn(1,N)*2; +sPress.addThresholdRule(struct(), 46, ... + 'Direction', 'upper', 'Label', 'Hi Warn', ... + 'Color', [1 0.8 0], 'LineStyle', '--'); +sPress.resolve(); + +% Flow Rate — weaker correlation with temperature +sFlow = Sensor('F-301', 'Name', 'Flow Rate'); +sFlow.Units = 'L/min'; +sFlow.X = t; +sFlow.Y = max(0, 100 + 0.4*(sTemp.Y - 72) + randn(1,N)*10); +sFlow.addThresholdRule(struct(), 130, ... + 'Direction', 'upper', 'Label', 'Hi Alarm', ... + 'Color', [1 0.2 0.2], 'LineStyle', '-'); +sFlow.resolve(); + +% Vibration — used as color sensor (highlights operating regime) +sVib = Sensor('V-501', 'Name', 'Vibration RMS'); +sVib.Units = 'mm/s'; +sVib.X = t; +sVib.Y = max(0.1, 1.2 + 0.06*(sPress.Y - 40) + randn(1,N)*0.3); +sVib.resolve(); + +%% 2. Build dashboard +d = DashboardEngine('Scatter Widget Demo'); +d.Theme = 'light'; + +% Row 1-8: Temperature vs Pressure — two sensors, no color (dot markers) +d.addWidget('scatter', 'Title', 'Temperature vs Pressure', ... + 'Position', [1 1 12 8], ... + 'SensorX', sTemp, ... + 'SensorY', sPress, ... + 'MarkerSize', 5); + +% Row 1-8: Temperature vs Flow — color-coded by Vibration (parula colormap) +d.addWidget('scatter', 'Title', 'Temperature vs Flow (color = Vibration)', ... + 'Position', [13 1 12 8], ... + 'SensorX', sTemp, ... + 'SensorY', sFlow, ... + 'SensorColor', sVib, ... + 'MarkerSize', 6, ... + 'Colormap', 'parula'); + +% Row 9-16: Pressure vs Flow — color-coded by Temperature, larger markers +d.addWidget('scatter', 'Title', 'Pressure vs Flow (color = Temperature)', ... + 'Position', [1 9 12 8], ... + 'SensorX', sPress, ... + 'SensorY', sFlow, ... + 'SensorColor', sTemp, ... + 'MarkerSize', 8, ... + 'Colormap', 'hot'); + +% Row 9-16: Flow vs Vibration — no color sensor, small markers +d.addWidget('scatter', 'Title', 'Flow vs Vibration', ... + 'Position', [13 9 12 8], ... + 'SensorX', sFlow, ... + 'SensorY', sVib, ... + 'MarkerSize', 4); + +%% 3. Render +d.render(); +fprintf('Dashboard rendered with %d scatter widgets.\n', numel(d.Widgets)); +fprintf('Correlation T-P: %.2f T-F: %.2f P-F: %.2f\n', ... + corr(sTemp.Y(:), sPress.Y(:)), ... + corr(sTemp.Y(:), sFlow.Y(:)), ... + corr(sPress.Y(:), sFlow.Y(:))); From 02be3c68b7cb3cf023acbe0de56a764dd63d9821 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Wed, 18 Mar 2026 22:59:19 +0100 Subject: [PATCH 12/12] fix(dashboard): reduce MultiStatusWidget nesting depth for lint Extract threshold color derivation into private deriveColor method to stay within the project's max control nesting depth of 5. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/Dashboard/MultiStatusWidget.m | 40 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/libs/Dashboard/MultiStatusWidget.m b/libs/Dashboard/MultiStatusWidget.m index 719ba3fe..1177faeb 100644 --- a/libs/Dashboard/MultiStatusWidget.m +++ b/libs/Dashboard/MultiStatusWidget.m @@ -58,24 +58,8 @@ function refresh(obj) cx = (col + 0.5) / cols; cy = 1 - (row + 0.5) / rows; - % Determine color from sensor thresholds sensor = obj.Sensors{i}; - color = okColor; - if ~isempty(sensor) && ~isempty(sensor.Y) - val = sensor.Y(end); - if ~isempty(sensor.ThresholdRules) - for k = 1:numel(sensor.ThresholdRules) - rule = sensor.ThresholdRules{k}; - if ~isempty(rule.Color) - if rule.IsUpper && val >= rule.Value - color = rule.Color; - elseif ~rule.IsUpper && val <= rule.Value - color = rule.Color; - end - end - end - end - end + color = obj.deriveColor(sensor, okColor); % Draw indicator r = 0.3 / max(cols, rows); @@ -128,6 +112,28 @@ function refresh(obj) end end + methods (Access = private) + function color = deriveColor(~, sensor, defaultColor) + color = defaultColor; + if isempty(sensor) || isempty(sensor.Y) + return; + end + val = sensor.Y(end); + if isempty(sensor.ThresholdRules) + return; + end + for k = 1:numel(sensor.ThresholdRules) + rule = sensor.ThresholdRules{k}; + if isempty(rule.Color), continue; end + if rule.IsUpper && val >= rule.Value + color = rule.Color; + elseif ~rule.IsUpper && val <= rule.Value + color = rule.Color; + end + end + end + end + methods (Static) function obj = fromStruct(s) obj = MultiStatusWidget();