Plot Widget Developer Guide
This guide covers the shared architecture, axis types, DataManager integration, and testing requirements for all plot widgets in src/WhiskerToolbox/Plots/.
Widget Inventory
| Widget | X-Axis Type | Y-Axis Type | Data Sources |
|---|---|---|---|
| LinePlotWidget | RelativeTimeAxis | VerticalAxis | AnalogTimeSeries (multiple series) |
| EventPlotWidget | RelativeTimeAxis | (trial rows) | DigitalEventSeries (multiple events) |
| PSTHWidget | RelativeTimeAxis | VerticalAxis | DigitalEventSeries (histogram bins) |
| HeatmapWidget | RelativeTimeAxis | VerticalAxis | AnalogTimeSeries (color-mapped) |
| ScatterPlotWidget | HorizontalAxis | VerticalAxis | Point pairs from two data keys |
| ACFWidget | HorizontalAxis | VerticalAxis | DigitalEventSeries (autocorrelation) |
| SpectrogramWidget | CenteredTimeAxis (planned) | VerticalAxis (planned) | AnalogTimeSeries / TensorData |
| TemporalProjectionViewWidget | HorizontalAxis | VerticalAxis | PointData / LineData |
| OnionSkinViewWidget | HorizontalAxis | VerticalAxis | PointData / LineData / MaskData |
| 3DPlotWidget | (3D) | (3D) | PointData |
Directory Structure
Every plot widget follows a consistent directory layout:
Plots/<WidgetName>/
├── CMakeLists.txt
├── <WidgetName>Registration.hpp # EditorRegistry registration module
├── <WidgetName>Registration.cpp
├── Core/
│ ├── <Widget>State.hpp # QObject state class (EditorState subclass)
│ ├── <Widget>State.cpp
│ └── <Widget>StateData.hpp # Plain serializable struct (rfl-compatible)
├── Rendering/ # OpenGL rendering (if applicable)
│ ├── <Widget>OpenGLWidget.hpp
│ └── <Widget>OpenGLWidget.cpp
└── UI/
├── <Widget>Widget.hpp # Main composite widget
├── <Widget>Widget.cpp
├── <Widget>PropertiesWidget.hpp # Properties/settings panel
├── <Widget>PropertiesWidget.cpp
└── <Widget>PropertiesWidget.ui # Qt Designer form
Axis Types
There are three axis types, each with an identical layered architecture:
AxisStateData (plain struct) → AxisState (QObject) → AxisWidget (QWidget) → AxisWithRangeControls (factory)
1. RelativeTimeAxis — Symmetric Time Windows
When to use: Plots aligned to events or intervals where X represents relative time centered around an alignment point (t = 0).
Components:
| Layer | Class | Location |
|---|---|---|
| Data | RelativeTimeAxisStateData |
Common/RelativeTimeAxisWidget/Core/ |
| State | RelativeTimeAxisState |
Common/RelativeTimeAxisWidget/Core/ |
| Widget | RelativeTimeAxisWidget |
Common/RelativeTimeAxisWidget/ |
| Factory | RelativeTimeAxisWithRangeControls |
Common/RelativeTimeAxisWidget/ |
State fields: - min_range (default -500.0) — left edge of the time window - max_range (default 500.0) — right edge of the time window
Signals: minRangeChanged, maxRangeChanged, rangeChanged, rangeUpdated
The RelativeTimeAxisWidget takes a ViewStateGetter (returns CorePlotting::ViewState) rather than a simple (min, max) pair, making it zoom/pan-aware.
Usage pattern in a plot state:
// In your state constructor:
_relative_time_axis_state = std::make_unique<RelativeTimeAxisState>(this);
// Initialize from alignment window:
double half_window = _data.alignment.window_size / 2.0;
_relative_time_axis_state->setRangeSilent(-half_window, half_window);
_data.time_axis = _relative_time_axis_state->data();
// Synchronize axis ↔ view state:
connect(_relative_time_axis_state.get(), &RelativeTimeAxisState::rangeChanged,
this, [this]() {
_data.time_axis = _relative_time_axis_state->data();
_data.view_state.x_min = _data.time_axis.min_range;
_data.view_state.x_max = _data.time_axis.max_range;
markDirty();
emit viewStateChanged();
emit stateChanged();
});Used by: LinePlotWidget, EventPlotWidget, PSTHWidget, HeatmapWidget
2. HorizontalAxis — Generic Value Axis
When to use: Plots where the X-axis represents an arbitrary numeric range (not relative time). Typical for scatter plots, ACF lag values, or generic data axes.
Components:
| Layer | Class | Location |
|---|---|---|
| Data | HorizontalAxisStateData |
Common/HorizontalAxisWidget/Core/ |
| State | HorizontalAxisState |
Common/HorizontalAxisWidget/Core/ |
| Widget | HorizontalAxisWidget |
Common/HorizontalAxisWidget/ |
| Factory | HorizontalAxisWithRangeControls |
Common/HorizontalAxisWidget/ |
State fields: - x_min (default 0.0) - x_max (default 100.0)
Signals: xMinChanged, xMaxChanged, rangeChanged, rangeUpdated
The HorizontalAxisWidget can take either a RangeGetter function or have its range set directly with setRange(min, max).
Usage pattern in a plot state:
// In your state constructor:
_horizontal_axis_state = std::make_unique<HorizontalAxisState>(this);
_data.horizontal_axis = _horizontal_axis_state->data();
_data.view_state.x_min = _horizontal_axis_state->getXMin();
_data.view_state.x_max = _horizontal_axis_state->getXMax();
// Synchronize axis ↔ view state:
auto syncHorizontalData = [this]() {
_data.horizontal_axis = _horizontal_axis_state->data();
markDirty();
emit stateChanged();
};
connect(_horizontal_axis_state.get(), &HorizontalAxisState::rangeChanged,
this, syncHorizontalData);
connect(_horizontal_axis_state.get(), &HorizontalAxisState::rangeUpdated,
this, syncHorizontalData);Used by: ScatterPlotWidget, ACFWidget, TemporalProjectionViewWidget, OnionSkinViewWidget
3. VerticalAxis — Y-Axis (Value or Trial Index)
When to use: Almost all 2D plots need a Y-axis. Can represent analog values, trial indices, bin counts, or spatial coordinates.
Components:
| Layer | Class | Location |
|---|---|---|
| Data | VerticalAxisStateData |
Common/VerticalAxisWidget/Core/ |
| State | VerticalAxisState |
Common/VerticalAxisWidget/Core/ |
| Widget | VerticalAxisWidget |
Common/VerticalAxisWidget/ |
| Factory | VerticalAxisWithRangeControls |
Common/VerticalAxisWidget/ |
| Helper | VerticalAxisSynchronizer |
Common/VerticalAxisWidget/ |
State fields: - y_min (default 0.0) - y_max (default 100.0)
Signals: yMinChanged, yMaxChanged, rangeChanged, rangeUpdated
Inversion support: The VerticalAxisWidget supports setInverted(true) for image-coordinate conventions (Y=0 at top, increasing downward).
ViewState synchronization helper:
#include "Plots/Common/VerticalAxisWidget/VerticalAxisSynchronizer.hpp"
// Sync vertical axis state to OpenGL pan/zoom changes:
syncVerticalAxisToViewState(
_vertical_axis_state.get(),
_state.get(),
[](auto const & vs) { return std::make_pair(vs.y_min, vs.y_max); }
);Used by: LinePlotWidget, PSTHWidget, HeatmapWidget, ScatterPlotWidget, ACFWidget, TemporalProjectionViewWidget, OnionSkinViewWidget
AxisMapping — Custom Label Formatting
All three axis widgets support an optional AxisMapping for custom label formatting. An AxisMapping provides:
worldToDomain(double world) → double— convert rendering coordinates to semantic valuesdomainToWorld(double domain) → double— reverse conversionformatLabel(double domain) → std::string— produce the tick label string
Factory functions in CorePlotting/CoordinateTransform/AxisMapping.hpp:
| Factory | Purpose |
|---|---|
identityAxis(title) |
No conversion, decimal formatting |
linearAxis(scale, offset, title, precision) |
Linear transform with configurable precision |
trialIndexAxis(total_trials) |
Integer trial labels (1-based) |
relativeTimeAxis(title) |
“+N” / “-N” / “0” formatting |
analogAxis(title, unit) |
Numeric with unit suffix |
// Example: Set trial index labels on a vertical axis
axis_widget->setAxisMapping(CorePlotting::trialIndexAxis(100));
// Example: Clear custom formatting
axis_widget->clearAxisMapping();Adding an Axis to a New Plot Widget
Step 1: Add axis state to your StateData struct:
struct MyPlotStateData {
std::string instance_id;
std::string display_name = "My Plot";
CorePlotting::ViewStateData view_state;
// Choose the appropriate axis types:
RelativeTimeAxisStateData time_axis; // or HorizontalAxisStateData
VerticalAxisStateData vertical_axis;
// Plus any alignment data:
PlotAlignmentData alignment;
};Step 2: Create axis state objects in your State constructor:
MyPlotState::MyPlotState(QObject * parent)
: EditorState(parent),
_relative_time_axis_state(std::make_unique<RelativeTimeAxisState>(this)),
_vertical_axis_state(std::make_unique<VerticalAxisState>(this))
{
// Initialize axis data from state
_data.time_axis = _relative_time_axis_state->data();
_data.vertical_axis = _vertical_axis_state->data();
// Sync view_state bounds to axis ranges
_data.view_state.x_min = _data.time_axis.min_range;
_data.view_state.x_max = _data.time_axis.max_range;
_data.view_state.y_min = _data.vertical_axis.y_min;
_data.view_state.y_max = _data.vertical_axis.y_max;
// Wire up synchronization (see patterns in existing widgets)
// ...
}Step 3: Create axis widgets in your UI using the factory:
// In the properties widget or main widget:
auto axis_system = createRelativeTimeAxisWithRangeControls(
_state->relativeTimeAxisState(),
plot_area, // parent for axis display widget
controls_area // parent for spinbox controls
);
auto y_axis_system = createVerticalAxisWithRangeControls(
_state->verticalAxisState(),
plot_area,
controls_area
);Step 4: Serialize/deserialize in toJson()/fromJson():
std::string MyPlotState::toJson() const {
return rfl::json::write(_data); // StateData contains all axis data
}
bool MyPlotState::fromJson(std::string const & json) {
auto result = rfl::json::read<MyPlotStateData>(json);
if (result) {
_data = *result;
// Restore axis states from deserialized data:
_relative_time_axis_state->data() = _data.time_axis;
_vertical_axis_state->data() = _data.vertical_axis;
emit stateChanged();
return true;
}
return false;
}The “Silent Update” Pattern
All axis states have two range-setting methods:
setRange(min, max)— Emits bothrangeChangedandrangeUpdated. Use when the user changes the range via spinboxes (Flow A: user input).setRangeSilent(min, max)— Emits onlyrangeUpdated, NOTrangeChanged. Use when the range changes from panning/zooming (Flow B: OpenGL interaction). This prevents feedback loops between the axis state and the view state.
The VerticalAxisSynchronizer helper template automates Flow B:
syncVerticalAxisToViewState(axis_state, plot_state, compute_bounds_fn);DataManager Integration
Overview
Plot widgets interact with the DataManager to: 1. Discover available data keys — populate combo boxes 2. Retrieve data for visualization — getData<T>(key) calls 3. React to key additions/removals — observer callbacks
The Observer Pattern
The DataManager exposes a simple observer API:
class DataManager {
public:
using ObserverCallback = std::function<void()>;
int addObserver(ObserverCallback callback); // Returns callback ID
void removeObserver(int callback_id); // Removes by ID
};The callback fires on any DataManager state change (data added, data deleted).
Required Integration Steps
1. Store the DataManager and Observer ID
class MyPropertiesWidget : public QWidget {
// ...
private:
std::shared_ptr<DataManager> _data_manager;
int _dm_observer_id = -1; // -1 means "no callback registered"
};2. Register the Observer in the Constructor
MyPropertiesWidget::MyPropertiesWidget(
std::shared_ptr<MyState> state,
std::shared_ptr<DataManager> data_manager,
QWidget * parent)
: QWidget(parent), _data_manager(std::move(data_manager))
{
if (_data_manager) {
_dm_observer_id = _data_manager->addObserver([this]() {
_populateComboBoxes();
});
}
}3. Remove the Observer in the Destructor
This is critical. Failing to remove the observer will leave a dangling this pointer in the DataManager’s callback list, causing undefined behavior (usually a crash) when the DataManager next fires observers.
MyPropertiesWidget::~MyPropertiesWidget()
{
if (_data_manager && _dm_observer_id != -1) {
_data_manager->removeObserver(_dm_observer_id);
}
}4. Handle Key Removal in Combo Box Repopulation
When the observer fires, re-query DataManager::getKeys<T>() to rebuild combo boxes. The deleted key will simply not appear. If possible, preserve the user’s current selection:
void MyPropertiesWidget::_populateComboBoxes() {
// Save current selection
QString current = _combo->currentData().toString();
_combo->clear();
auto keys = _data_manager->getKeys<AnalogTimeSeries>();
for (auto const & key : keys) {
_combo->addItem(QString::fromStdString(key),
QString::fromStdString(key));
}
// Restore selection if still valid
int idx = _combo->findData(current);
if (idx >= 0) {
_combo->setCurrentIndex(idx);
}
}Handling Stale Keys in Visualizations
When a user adds a data key to a plot (e.g., adds “signal_A” as a LinePlot series) and then that key is deleted from the DataManager, the plot state still references the deleted key. Widgets should handle this gracefully:
- In rendering code: Always null-check
getData<T>(key)results before use. - In the observer callback: After repopulating combo boxes, optionally check whether any currently visualized keys are now missing and auto-remove them:
void MyPropertiesWidget::_onDataManagerChanged() {
_populateComboBoxes();
// Purge stale visualization keys
auto all_keys = _data_manager->getAllKeys();
std::set<std::string> valid_keys(all_keys.begin(), all_keys.end());
for (auto const & name : _state->getPlotSeriesNames()) {
auto opts = _state->getPlotSeriesOptions(name);
if (opts && valid_keys.find(opts->series_key) == valid_keys.end()) {
_state->removePlotSeries(name); // Key no longer exists
}
}
}Note: Currently not all widgets implement stale key purging. This is a gap that should be addressed — see the testing section below for how to verify this.
Common Testing Requirements
Every plot properties widget that registers a DataManager observer should be tested for the following behaviors. These tests verify the contract between the widget and the DataManager and prevent dangling reference bugs.
Test Categories
| Category | What It Verifies |
|---|---|
| Combo box population | Combo box correctly reflects current DataManager keys |
| Observer refresh | Adding/removing data updates the combo box in real-time |
| Destruction cleanup | Observer is removed in destructor; no crash after widget dies |
| Stale key handling | Visualized keys that are deleted from DM are handled gracefully |
Existing Per-Widget Tests
Each properties widget has its own test file following an identical pattern:
LinePlotPropertiesWidget.test.cppEventPlotPropertiesWidget.test.cppACFPropertiesWidget.test.cppPSTHPropertiesWidget.test.cppHeatmapPropertiesWidget.test.cpp
Template-Based Common Tests
Because the test logic is nearly identical across widgets, we provide a TEMPLATE_TEST_CASE-based common test suite in tests/WhiskerToolbox/Plots/Common/PlotWidgetCommonTests.hpp. This header defines parameterized tests that any plot widget can instantiate by providing a traits class.
See tests/WhiskerToolbox/Plots/Common/PlotWidgetCommonTests.hpp for the trait requirements and PlotWidgetCommonTests.test.cpp for instantiations covering LinePlotWidget, EventPlotWidget, and ACFWidget.