Scatter Plot Widget
Scatter Plot Widget Overview
The Scatter Plot Widget renders 2D scatter plots of paired numeric data, where each point represents a single TimeFrameIndex observation. The primary data sources are AnalogTimeSeries and individual columns of TensorData. Users can select any combination of these for the X and Y axes, configure glyph appearance (shape, size, color), interact via zoom/pan with click-to-navigate, and select points to add their associated TimeFrameIndex entities to groups.
Unlike the trial-aligned plot widgets (LinePlotWidget, EventPlotWidget, PSTHWidget, HeatmapWidget), the Scatter Plot Widget does not use PlotAlignmentState or GatherResult. Instead, it reads data directly from AnalogTimeSeries or TensorData column vectors indexed by TimeFrameIndex.
Architecture
The widget follows the standard plot widget directory layout:
Plots/ScatterPlotWidget/
├── CMakeLists.txt
├── ScatterPlotWidgetRegistration.hpp # EditorRegistry registration module
├── ScatterPlotWidgetRegistration.cpp
├── Core/
│ ├── ScatterAxisSource.hpp # Data source descriptor struct (key, column, offset)
│ ├── ScatterPlotState.hpp # QObject state class (EditorState subclass)
│ ├── ScatterPlotState.cpp
│ ├── ScatterPointData.hpp # Result struct for computed scatter points
│ ├── BuildScatterPoints.hpp # Factory function for point pair computation
│ ├── BuildScatterPoints.cpp
│ ├── SourceCompatibility.hpp # Row-type compatibility validation
│ └── SourceCompatibility.cpp
├── Rendering/
│ ├── ScatterPlotOpenGLWidget.hpp # OpenGL rendering + mouse interaction
│ └── ScatterPlotOpenGLWidget.cpp
└── UI/
├── ScatterPlotWidget.hpp # Main composite widget (axes + OpenGL + layout)
├── ScatterPlotWidget.cpp
├── ScatterPlotWidget.ui
├── ScatterPlotPropertiesWidget.hpp # Properties/settings panel
├── ScatterPlotPropertiesWidget.cpp
└── ScatterPlotPropertiesWidget.ui
Component Overview
| Component | Responsibility |
|---|---|
ScatterPlotWidget (UI) |
Top-level Qt widget — composes OpenGL canvas, horizontal axis, vertical axis |
ScatterPlotPropertiesWidget (UI) |
Properties panel — data source selection (key, column, offset per axis), reference line toggle, compatibility label, axis range controls |
ScatterPlotOpenGLWidget (Rendering) |
OpenGL canvas — point rendering via SceneRenderer, reference line, auto-fit bounds, mouse interaction (pan, zoom) |
ScatterPlotState (Core) |
Serializable state — view state, axis ranges, data source configuration, reference line toggle |
ScatterAxisSource (Core) |
Describes a single axis data source (key, tensor column, temporal offset) |
ScatterPointData (Core) |
Result struct for computed scatter points (parallel x, y, TimeFrameIndex vectors) |
BuildScatterPoints (Core) |
Factory function for computing point pairs from two axis sources |
SourceCompatibility (Core) |
Row-type compatibility validation between two axis sources |
ScatterPlotWidgetRegistration |
Registers type with EditorRegistry (state, view, properties factories) |
EditorState Pattern
The Scatter Plot Widget follows the project’s EditorState architecture:
ScatterPlotStateinherits fromEditorState- Registered with
EditorRegistryviaScatterPlotWidgetModule::registerTypes() ScatterPlotWidgetis the view factory,ScatterPlotPropertiesWidgetis the properties factory- Both share the same
ScatterPlotStateinstance - Properties placed in Right zone, view placed in Center zone (default)
- The
create_editor_customfactory inScatterPlotWidgetRegistrationhandles coupling the view and properties widgets, connecting range controls and forwardingtimePositionSelectedtoEditorRegistry::setCurrentTime()
State Management
ScatterPlotStateData
The serializable state struct contains:
struct ScatterPlotStateData {
std::string instance_id;
std::string display_name = "Scatter Plot";
CorePlotting::ViewStateData view_state;
HorizontalAxisStateData horizontal_axis;
VerticalAxisStateData vertical_axis;
std::optional<ScatterAxisSource> x_source; ///< X-axis data source
std::optional<ScatterAxisSource> y_source; ///< Y-axis data source
bool show_reference_line = false; ///< Show y=x reference line
/// Glyph style for scatter points (shape, size, color, alpha)
CorePlotting::GlyphStyleData glyph_style{GlyphType::Circle, 5.0f, "#3388FF", 0.8f};
};The x_source and y_source fields describe which data to plot on each axis. When both are set and compatible, the widget computes and renders scatter points. The show_reference_line field toggles the \(y = x\) diagonal line.
Signal Architecture
ScatterPlotState emits:
| Signal | Trigger |
|---|---|
viewStateChanged |
Zoom, pan, or bounds changed |
xSourceChanged |
X-axis data source key, column, or offset changed |
ySourceChanged |
Y-axis data source key, column, or offset changed |
referenceLineChanged |
Reference line show/hide toggled |
Performance-critical separation: Zoom and pan operations emit only viewStateChanged(), which updates the projection matrix without triggering an expensive scene rebuild. Source changes emit their specific signal plus stateChanged() to trigger a full scene rebuild (recomputing point pairs and re-uploading GPU buffers).
Axis States
ScatterPlotState owns both axis states:
HorizontalAxisState— X-axis (generic value axis, not relative time). See Plot Widget Guide for full axis documentation.VerticalAxisState— Y-axis (generic value axis). Supports the “silent update” pattern to prevent feedback loops during zoom/pan.
Both axis states synchronize bidirectionally with the view state:
- User changes axis range via spinboxes → view state zoom/pan updated → projection refreshed
- User zoom/pans in OpenGL → axis ranges updated silently via
setRangeSilent()(no feedback loop)
Data Sources
Overview
The Scatter Plot Widget pairs two numeric data sources — one for the X-axis and one for the Y-axis. The supported source types are:
| Source Type | Row Index Type | How Values Are Read |
|---|---|---|
AnalogTimeSeries |
Always TimeFrameIndex |
Direct value at each time frame index |
TensorData column |
TimeFrameIndex or Interval (via RowDescriptor) |
getColumn(name) or getColumn(index) |
Row Type Compatibility
The fundamental constraint is that both the X and Y sources must share a common row type so each scatter point maps to a single observation. For AnalogTimeSeries, the row type is always TimeFrameIndex. For TensorData, the row type is determined by its RowDescriptor:
RowType::TimeFrameIndex— directly compatible withAnalogTimeSeries; rows map 1:1 via sharedTimeFrameIndexvaluesRowType::Interval— each row corresponds to aTimeFrameInterval; to pair withAnalogTimeSeries, the analog signal must be aggregated (e.g., mean, median) over each intervalRowType::Ordinal— rows have no temporal meaning; can only be paired with another ordinal tensor of the same row count
This validation is implemented by checkSourceCompatibility() in Core/SourceCompatibility.hpp. The function resolves each source’s row type via resolveSourceRowType() (which queries the DataManager for the key’s data type and, for TensorData, the RowDescriptor::type()) and returns a SourceCompatibilityResult with a compatible flag and a human-readable warning_message when sources are incompatible. The properties panel should display this warning and disable rendering when sources are incompatible.
TensorData Column Selection
When a TensorData key is selected for either axis, an additional combo box appears below to select which column to use. TensorData supports named columns via getColumnNames(), so the combo box displays column names when available, falling back to numeric indices (0, 1, 2, …) otherwise.
Temporal Offset
An optional temporal offset allows plotting a value against its own shifted version (e.g., \(x(t)\) vs \(x(t-1)\)). The offset is specified in units of TimeFrameIndex steps and applies to either axis independently via the ScatterAxisSource::time_offset field.
Outlier detection use case: Plotting MaskArea(t) vs MaskArea(t-1) (same source key on both axes, with time_offset = -1 on the Y-axis) produces a scatter where:
- Points near the \(y = x\) diagonal represent slow changes
- Points far from the diagonal represent rapid transitions (potential outliers)
When the user sets time_offset = -1 for the Y-axis source:
- For each
TimeFrameIndex\(t\), the X value is \(x(t)\) and the Y value is \(x(t-1)\) - Points where the offset pushes \(t\) outside the valid range are excluded
- The
TimeFrameIndexstored per point is the “primary” index (X-axis, no offset)
The reference line (\(y = x\)) is particularly useful in this mode — it immediately shows which points represent rapid changes (far from diagonal) vs. slow changes (near diagonal). Each axis has an offset spinbox (range: -10000 to 10000) in the properties panel.
Rendering Pipeline
Data Flow
ScatterPlotState (data source config: keys, columns, offsets)
↓
buildScatterPoints(DataManager, x_source, y_source)
↓
ScatterPointData { x_values, y_values, time_indices }
↓
Auto-fit bounds (5% padding, applied on first data load)
↓
SceneBuilder::addGlyphs() → MappedElement per point
SceneBuilder::addPolyLine() → y=x reference line (optional)
↓
SceneBuilder::build() → RenderableScene
↓
SceneRenderer::uploadScene() + render(view, projection)
Point Rendering
Points are rendered via SceneBuilder::addGlyphs() using the shared glyph infrastructure from CorePlotting. Each scatter point is a MappedElement with its (x, y) position and an EntityId for hit testing. The GlyphRenderer uploads all points as a single GPU buffer and renders them in one instanced draw call, providing good performance for large point counts.
During the same addGlyphs() traversal, each point is also inserted into the scene’s QuadTree<EntityId> spatial index. This index is used by SceneHitTester::queryQuadTree() for O(log n) nearest-neighbor lookup during double-click-to-navigate and hover tooltip interactions, with no additional data structures required.
Default glyph style: blue circles (#3388FF), size 5, alpha 0.8. See Visual Customization for the full glyph style system.
Reference Line
The \(y = x\) reference line is rendered via SceneBuilder::addPolyLine() with two vertices spanning the visible viewport diagonal. Endpoints are recomputed on pan/zoom to ensure the line always extends across the full visible area. Style: gray (#888888), alpha 0.5.
Auto-Fit Bounds
When scatter points are computed for the first time (bounds still at default 0–100), the widget scans for min/max X and Y values, adds 5% padding, and calls setXBounds()/setYBounds() on the state. This ensures the data fills the viewport without manual range adjustment.
Scene Rebuild Strategy
The scene is rebuilt (marked dirty) when:
- X or Y source changes (new data key, column, or offset)
- Glyph style changes (shape, size, color, alpha)
- Reference line is toggled (show/hide)
- View bounds change while reference line is shown (endpoints depend on viewport)
Visual Customization
The scatter plot uses the project-wide CorePlotting::GlyphStyleData / GlyphStyleState / GlyphStyleControls stack to give users control over point appearance. The same stack is used by TemporalProjectionViewWidget, OnionSkinViewWidget, and EventPlotWidget.
Serializable Style Data
CorePlotting::GlyphStyleData (in CorePlotting/DataTypes/GlyphStyleData.hpp) is a plain struct with no Qt dependencies, suitable for embedding directly in state data structs and serializing with rfl::json:
struct GlyphStyleData {
GlyphType glyph_type = GlyphType::Circle; // marker shape
float size = 5.0f; // radius in pixels
std::string hex_color = "#007bff"; // hex color string
float alpha = 1.0f; // transparency [0, 1]
};ScatterPlotStateData embeds this as:
CorePlotting::GlyphStyleData glyph_style{GlyphType::Circle, 5.0f, "#3388FF", 0.8f};Available GlyphType values: Circle, Square, Tick, Cross, Diamond, Triangle. (Diamond and Triangle fall back to Circle in the current renderer.)
QObject State Wrapper
ScatterPlotState owns a std::unique_ptr<GlyphStyleState> (Plots/Common/GlyphStyleWidget/Core/GlyphStyleState.hpp). GlyphStyleState is a QObject that wraps GlyphStyleData and emits:
| Signal | When Emitted | Purpose |
|---|---|---|
styleChanged() |
User edits a property | Triggers scene rebuild |
styleUpdated() |
setStyleSilent() (e.g., on fromJson) |
Refreshes UI without rebuild |
The state class wires these up in its constructor:
// Initialize GlyphStyleState from serialized data
_glyph_style_state->setStyleSilent(_data.glyph_style);
// Propagate user changes back to serializable struct and notify renderer
connect(_glyph_style_state.get(), &GlyphStyleState::styleChanged, this, [this]() {
_data.glyph_style = _glyph_style_state->data();
markDirty();
emit glyphStyleChanged();
emit stateChanged();
});The glyphStyleState() accessor returns the raw pointer for binding to GlyphStyleControls.
Properties Panel Controls
A “Glyph Options” collapsible Section in ScatterPlotPropertiesWidget embeds GlyphStyleControls, which provides four controls laid out as a QFormLayout:
| Control | Type | What It Sets |
|---|---|---|
| Shape | QComboBox |
GlyphType (Circle / Square / Tick / Cross / Diamond / Triangle) |
| Size | QDoubleSpinBox |
Radius in pixels (0.5 – 50 px, step 0.5) |
| Color | QPushButton |
Opens QColorDialog; stores as hex string |
| Alpha | QDoubleSpinBox |
Transparency (0.0 – 1.0, step 0.05) |
The controls use the anti-recursion _updating_ui guard pattern to avoid feedback loops when the state updates the UI.
Rendering Conversion
ScatterPlotOpenGLWidget::rebuildScene() reads the style at scene build time via _state->glyphStyleState()->data() and converts it to rendering types using two free functions from CorePlotting/DataTypes/GlyphStyleConversion.hpp:
auto const & glyph_data = _state->glyphStyleState()->data();
CorePlotting::GlyphStyle point_style;
point_style.glyph_type = CorePlotting::toRenderableGlyphType(glyph_data.glyph_type);
point_style.size = glyph_data.size;
point_style.color = CorePlotting::hexColorToVec4(glyph_data.hex_color, glyph_data.alpha);
builder.addGlyphs("scatter_points", elements, point_style);toRenderableGlyphType() maps the serializable GlyphType enum to the renderer-level RenderableGlyphBatch::GlyphType. hexColorToVec4() parses a "#RRGGBB" string and applies the separate alpha value, returning a glm::vec4 for the shader.
The glyphStyleChanged signal from ScatterPlotState is connected in ScatterPlotOpenGLWidget::setState() to set _scene_dirty = true and call update(), ensuring the next paint picks up the new style without any extra bookkeeping.
Interaction
Zoom and Pan
The widget supports separated X/Y zoom for independent axis exploration, using the shared PlotInteractionHelpers:
| Input | Action |
|---|---|
| Mouse wheel | X-zoom only |
| Shift + wheel | Y-zoom only |
| Ctrl + wheel | Uniform zoom (both axes) |
| Click + drag | Pan in both axes |
Zoom and pan update only the view projection (no scene rebuild), providing smooth interactive performance. The PlotInteractionHelpers are shared across all OpenGL plot widgets — see Plot Widget Guide.
Hover Highlight
When the cursor dwells over a scatter point, the nearest point is highlighted to give visual feedback before a double-click or selection action.
Visual style: The hovered point is drawn at the same color it already has (preserving any feature-based or fixed coloring) but at a larger size, making it visually distinct without changing its semantic meaning.
Hit testing: Uses the same SceneHitTester/QuadTree infrastructure as double-click navigation. A single-shot QTimer (~400 ms) debounces rapid mouse movement; the hit test fires only when the cursor stops moving. The timer is cancelled and any visible highlight is cleared immediately when a pan or zoom gesture begins.
Rendering: Because GlyphRenderer uses u_point_size as a per-batch uniform (not per-instance), the hovered point must be issued as a separate addGlyphs() call at the desired larger size. Per-instance color is handled directly in the shader (a_instance_color attribute on the instanced VBO), so the hover batch re-uses the original point color without any additional CPU-side logic. This results in one extra GPU draw call for the single hovered point.
See roadmap Phase 8 for the complete dwell-timer and tooltip implementation plan.
Hover Tooltip
When the cursor dwells over a scatter point (~500 ms), a tooltip appears showing the point’s X and Y coordinates and its TimeFrameIndex. The tooltip uses the shared PlotTooltipManager (Plots/Common/TooltipManager/), the same infrastructure used by EventPlotWidget.
Hit test provider: Reuses the existing hitTestPointAt() method (which queries the QuadTree<EntityId> via SceneHitTester) and passes the scatter data index as user_data.
Text provider: Formats the tooltip as:
X: 1.234
Y: 5.678
Index: 42
Suppression: Tooltips are automatically suppressed during pan and polygon interaction by passing _is_panning || _polygon_controller->isActive() to onMouseMove(). The tooltip is also hidden when the mouse leaves the widget via leaveEvent().
Point Selection
Points can be selected via polygon (lasso) drawing. Selected points are visually distinguished from unselected points and can be added to an entity group.
Visual style: Selected points are drawn at the same size as normal points but with a distinct selection color (e.g., red). Keeping the same size means no per-batch size change is needed — only color differs.
Rendering: Since color is stored as a per-instance attribute in the shader (a_instance_color on the instanced VBO), selected points can be rendered with any color without rebuilding the main point batch. The selected subset is issued as a separate addGlyphs() call with the selection color, rendering on top of the unselected points. This results in one extra GPU draw call for the entire selected set, regardless of how many points are selected.
The polygon drawing interaction uses the shared PolygonInteractionController from CorePlotting/Interaction/. After the polygon is closed, selectPointsInPolygon() (a pure geometry function in CorePlotting/Selection/) identifies which points fall inside the polygon. Selected point indices are stored in ScatterPlotStateData::selected_indices and cleared when the data source changes.
See roadmap Phase 3 for the full polygon selection and group integration implementation plan.
Axis System
Horizontal Axis (X)
Uses the shared HorizontalAxisWidget with HorizontalAxisState. The axis uses identityAxis("X", 0) mapping. Range is synchronized bidirectionally with the view state using setRangeSilent().
This is the same axis type used by ACFWidget, TemporalProjectionViewWidget, and OnionSkinViewWidget. See Plot Widget Guide for full axis integration details.
Vertical Axis (Y)
Uses VerticalAxisWidget with VerticalAxisState. Range is auto-adjusted when data bounds are computed, and synchronized bidirectionally with the view state.
Both axis widgets provide collapsible range control sections (HorizontalAxisRangeControls and VerticalAxisRangeControls) that are inserted into the properties panel when setPlotWidget() is called.
Properties Panel
The ScatterPlotPropertiesWidget provides the following sections:
Data Source Selection
A “Data Sources” collapsible section contains controls for both X and Y axes:
Per axis (X and Y):
- Key combo box — lists all
AnalogTimeSerieskeys (prefixed[ATS]) andTensorDatakeys (prefixed[Tensor]) from DataManager - Column combo box — visible only when a
TensorDatakey is selected; populated with column names fromTensorData::getColumnNames(), falling back to numeric indices (0, 1, 2, …) if no names are set - Offset spinbox — integer spinbox for temporal offset in
TimeFrameIndexsteps (default: 0, range: -10000 to 10000) - Compatibility label — shows “Sources are compatible” (green) or a descriptive warning (red) based on
checkSourceCompatibility()result
The combo boxes are kept in sync with DataManager via the observer pattern: an observer is registered in the constructor (_data_manager->addObserver()) and removed in the destructor (_data_manager->removeObserver()). When the observer fires, all key combo boxes are repopulated while preserving the current selection.
Reference Line
A “Reference Line” collapsible section contains:
- Show reference line checkbox — toggles the \(y = x\) diagonal line
Glyph Options
A “Glyph Options” collapsible section contains GlyphStyleControls (shape, size, color, alpha). Changes propagate immediately to the rendered scene via glyphStyleChanged. See Visual Customization for the full architecture.
Axis Range Controls
Created when setPlotWidget() is called:
- X-Axis Range Controls —
HorizontalAxisRangeControlsin a collapsibleSection - Y-Axis Range Controls —
VerticalAxisRangeControlsin a collapsibleSection
Planned Additions
See the roadmap for planned additions including:
- Point selection and group management controls
- Feature-based coloring controls
Relationship to Other Widgets
Feature Parallels with Other Widgets
| Feature | ScatterPlot | Similar Implementation In |
|---|---|---|
| Double-click-to-navigate | Implemented (double-click → TimeFrameIndex via SceneHitTester) |
EventPlotWidget (double-click → absolute time) |
| Polygon selection | Planned (lasso → TimeFrameIndex set) |
TemporalProjectionViewWidget (planned for PointData) |
| Glyph customization | Implemented (GlyphStyleData / GlyphStyleState / GlyphStyleControls) |
EventPlotWidget (Tick/Circle/Square), TemporalProjectionViewWidget (point glyphs) |
| Selection → Group | Planned (TimeFrameIndex → entity group) |
LinePlotWidget (trial indices → alignment event groups) |
| TensorData integration | Planned (column as axis source) | EventPlotWidget (per-trial feature coloring/sorting) |
Key Architectural Distinction
Unlike the trial-aligned widgets (LinePlot, EventPlot, PSTH, Heatmap), the Scatter Plot Widget:
- Does not use
PlotAlignmentStateorPlotAlignmentWidget - Does not use
GatherResultfor trial aggregation - Reads data directly from
AnalogTimeSeriesandTensorDatacolumns - Each point corresponds to a single
TimeFrameIndexrather than a trial
This makes it more similar in structure to TemporalProjectionViewWidget and ACFWidget than to the trial-aligned family.
Testing
Existing Tests
tests/WhiskerToolbox/Analysis_Dashboard/Analysis_Dashboard.test.cpp— VerifiesScatterPlotWidgetcreation viaPlotFactorytests/WhiskerToolbox/Plots/ScatterPlotWidget/SourceCompatibility.test.cpp— Row-type compatibility validation (21 test cases coveringresolveSourceRowType, all compatible pairings, all incompatible pairings, and missing/unknown sources)tests/WhiskerToolbox/Plots/ScatterPlotWidget/BuildScatterPoints.test.cpp— Point pair computation covering all 6 source type combinations (ATS×ATS, ATS×TensorTFI, TensorTFI×ATS, TensorTFI×TensorTFI, Ordinal×Ordinal, Interval×Interval), temporal offset, empty/missing keys, incompatible sources, named columns, andScatterPointDatastruct operationstests/WhiskerToolbox/Plots/ScatterPlotWidget/ScatterPlotPropertiesWidget.test.cpp— Properties widget DataManager integration (combo box population, observer refresh on data add/remove, destruction cleanup, stale key handling, state synchronization)
See Also
- Plot Widget Guide — Shared architecture, axis types, DataManager integration, testing
- ScatterPlotWidget Roadmap — Planned features and development phases
- EventPlotWidget — Glyph customization, click-to-navigate patterns
- LinePlotWidget — Selection → group integration pattern
- TemporalProjectionViewWidget — Polygon selection with PointData, shared axis types