doc/claude/architecture/dashboard.md
Part of the architecture corpus (index). Read this file in full before touching
UI::Dashboard, any widget, the plot/time-ring/sweep/waterfall render paths, alarms, workspaces, or output widgets. Dashboard ingest is hotpath — read dataflow.md too, and let thess-hotpathskill fire.
Dashboard::hotpathRxFrame does no per-frame container lookups; everything is resolved at
reconfigure and the per-frame walk is pointer-only.
m_valuePushes, built by buildValuePushes per source in row-major
group/dataset order from m_datasetReferences): updateDashboardData walks it positionally
and validates each entry's uniqueId against the incoming dataset (mismatch or unmapped UID →
handleMissingDataset, the same reconfigure-and-retry-once semantics the old per-dataset
QHash::find provided).Dataset::value
(a QString COW bump per target) only into stringTargets: DataGrid-group copies and the
m_lastFrame copies (dashboard.getData serializes that frame, incl. Keys::Value).
Non-numeric datasets write the string to every target. A new widget that displays
Dataset::value must be registered in buildValuePushes' string_targets set or its
tiles silently read stale strings.m_fftPushes,
m_waterfallPushes, m_gpsPushes, m_plot3DPushes): raw sourceId / value / buffer
pointers resolved in the matching configure* (second pass, after the buffers stop growing),
dropped by clearPushTables() on reset, and sharing the m_layoutValid staleness contract
with LinePush. GPS keeps the per-axis isNumeric gate via pointer (GpsPush::Field).DSP::FixedQueue<QVector3D> rings (m_plot3DRings, O(1)
overwrite — the old erase(begin()) was an O(points) memmove per frame); plotData3D()
materializes the ordered snapshot (m_plotData3D, mutable) at read/render cadence. A live
points() change is absorbed by an [[unlikely]] ring->resize() in updatePlot3DSeries.runAndReport adds a same-project isolation pass — lua+dashboard(off) runs
the all-widget project with dashboardIngest=false (Dashboard early-returns) and prints
dashboard ingest costs N.NNx / HOTPATH_DASHBOARD_INGEST_COST. Optimize against that
number; the historical dashboard costs N.NNx line compares two different projects.UI::AlarmMonitorAlarm-band notifications are dataset-level, not widget-level. UI::AlarmMonitor (singleton,
wired in ModuleManager::setupCrossModuleConnections) rebuilds per-dataset trackers from
Dashboard::datasets() on widgetCountChanged / dataReset and evaluates them on updated()
(UI rate, not hotpath). Trackers resolve datasets by uniqueId on every pass — never cache
Dataset* across signals; resetData(true) emits updated() before widgetCountChanged,
so cached pointers would dangle. Consequences:
hideOnDashboard.Bar / Gauge / Meter / LEDPanel are display-only band consumers; do not re-add
per-widget NotificationCenter posts (that double-fires when a dataset is both a band
widget and led: true).AlarmBand.blink (Keys::Blink, JSON blink, default false) is rendering-only: LED panels
flash while the band is active. LED datasets with no bands synthesize a runtime
[ledHigh, +inf) band inside LEDPanel (severity -1 = dataset color); nothing is migrated
in the project file — the editor only pre-fills a band from ledHigh when the dialog opens.The four tools (terminal/Console, notification log [Pro], clock, stopwatch) are never canvas
widgets. reconfigureDashboard registers them in the widget map unconditionally (predicate:
SerialStudio::isDashboardTool); Taskbar::rebuildModel skips them, so they never appear in
workspaces, search, or saved canvas layouts. The Dashboard::*Enabled flags are pure
view-state: setters persist to QSettings and emit only their own changed signal — toggling a
tool must not emit widgetCountChanged or touch the widget map (that re-introduces the
full dashboard rebuild this design removed). DashboardCanvas.qml::syncToolWindows maps each
flag to an ExternalWidgetWindow; a user closing the window flips the flag back, so
enabled == window visible. Tool windows are excluded from the per-project externalWindows
widgetSettings entry (their flags already persist globally).
Dataset::xAxisId selects the plot X source, and
there are three live modes: kXAxisTime (-2) the default, kXAxisSamples (-1), or a
dataset uniqueId (>=0) (Frame.h). Time and Samples are free; dataset-as-X stays
Pro/Trial-gated (the SerialStudio::datasetXAxisEnabled() predicate in Plot.cpp /
Dashboard::registerXAxisIfNeeded). When unlicensed or Trial-expired, a dataset-X plot
silently degrades to Samples: the dataset-X branch in configureLineSeries fails its gate
and falls through to the shared Samples carrier. Samples is live: a shared monotonic index ring
(m_pltXAxis, fillRange) + the per-dataset y ring, rendered via downsampleMonotonic.
Deserialize preserves -1 verbatim (Frame.cpp:301, ss_jsr(obj, Keys::XAxis, kXAxisTime));
migrateLegacyXAxisIds (ProjectModelLoading.cpp:158) keeps Time and Samples untouched, remaps
legacy positive frame-indices to dataset uniqueIds, and maps any other <= 0 / unresolvable id
to Time. Selector reality: the per-dataset X combo lists Time | Samples | every dataset
(ProjectModel::xDataSources); the multiplot group combo is Time | Samples only and fans the
chosen value into every member dataset's xAxisId (ProjectEditor kGroupView_xAxis), read back
canonically from datasets.front() (useTimeXAxisGroup), a known encoding wart slated for a
group-level xAxisId field. Carrier invariant: m_pltValues holds one DSP::LineSeries per
plot widget including time plots (index-aligned with the widget list; m_pltValues.size() != plotCount is the reconfigure trigger, Dashboard.cpp:2008, asserted at :2151); a time plot's
carrier is effectively a placeholder, since its curve renders from the TimeRing, not the
carrier's y ring. Time plots do NOT use the raw sample ring. They use a per-curve
DSP::TimeRing (DSP.h): a bounded (time, value) ring that decimates on ingest to a
min/max envelope pair per interval = 2 * windowSec / capacity second cell (two slots
reserved per cell so a saturated source still spans the window). Cell boundaries sit on an
absolute time grid and appendDecimated (DSP.h) maintains the open cell's slots in
place, so both envelope edges survive, slot contents are independent of sampling phase (no
beat aliasing / shimmer -- the old drifting single peak-pick had both), and the newest
sample is visible immediately at any input rate. Capacity is sized in Dashboard.cpp by
timeRingCapacity(plotTimeRangeSec): min(plotTimeRange * kAssumedMaxRateHz, kMaxTimeRingSamples)
with a floor of kDefaultPlotBuckets (50000 Hz assumption, 262144 cap, 1024 floor). Storage
is m_plotTimeRings / m_multiplotTimeRings (keyed by widget index; the multiplot one is a
std::vector<TimeRing> per curve). The hotpath appends numericValue at m_plotDisplayTimeSec
via m_timePushes (single plots) and m_multiplotPushes with its TimeCurve list (multi). The
widget side calls Dashboard::plotTimeRing(idx) / multiplotTimeRings(idx) and renders through
DSP::downsampleTimeWindow(ring.time, ring.value, ...): a viewport decimation of the
already-decimated ring whose pixel columns are bucketed on an absolute column-width lattice
(anchor quantized to the column width, drawing still uses true newest-rebased positions), so
per-column sample membership stays stable as the window slides -- a newest-anchored bucket grid
re-grouped every render and shimmered like heat haze. This is why 10 s of 48 kHz audio works:
the ring caps at kMaxTimeRingSamples and appendDecimated collapses bursts into bounded
envelope slots, bounded memory/CPU, axis fixed at [-T, 0] (never recompute the axis from raw
extremes). Display
clock (m_plotDisplayTimeSec, hotpathRxFrame): sources without a cadence stamp many frames
at one coarse wall-clock tick (~15 ms on Windows), which would compress them onto a single
decimator interval and lose temporal spread; the display clock spreads same-timestamp frames
by a smoothed per-sample period so sub-tick windows still render. It is self-correcting
(n samples over a gap fill it exactly) and display-only: m_relativeFrameTimeSec and exported
timestamps stay raw. Fine-timestamp sources (audio) hit the n==1 path and are unchanged. Ticks
render the magnitude in an adaptive unit (PlotWidget.qml timeAxis + secondsAgoFormat
timeUnitFactor/timeUnitName): the title and ticks switch between s / ms / us from
the span, so e.g. a 10 ms window reads Time (ms) with 10 8 6 4 2 0. Dataset-X plots, Samples
plots, FFT, GPS, 3D keep the raw-ring + downsample path.All three downsamplers in DSP.h (downsampleMonotonic,
downsampleTimeWindow, downsampleWindowAbsolute) are single-pass — the visible span resolves
via dsLowerBound/dsUpperBound binary searches (monotonic X/time is a hard precondition,
including for downsampleMonotonic's endpoint-derived X bounds), the bucket accumulation is the
only walk over the samples, and the Y bounds come from the filled columns (dsColumnYBounds,
O(columns)). Visible-window push: PlotCommon.setDownsampleFactor differentiates — time-axis
plots get dataW = plotArea.width (no zoom multiplier) plus model.setVisibleXWindow(xVisibleMin, xVisibleMax) (re-pushed from onXVisibleMinChanged, so pan updates it too), and the models
intersect it with the full range (clampToVisibleX) before downsampling — zooming in narrows
the binary-searched sample scan instead of re-bucketing the full range at zoom resolution.
Non-time plots (FFT, dataset-X, samples-axis) keep dataW = width * zoom. Draw cadence is
TimerEvents::uiTimeout — 60 Hz default, user-configurable 1-240 (uiRefreshRate setting), so
per-draw costs scale with that, not a fixed rate.
Widgets::PlotCurve)Plot, FFT, and MultiPlot curves render through a
custom scene-graph item (independent per-segment quads, 8 verts + 18 indices per visible segment,
each extruded along its own perpendicular — shared join cross-sections collapse to hairlines on
near-reversals — with a 1 px feather band straddling the stroke edge for AA without MSAA) instead
of QtGraphs LineSeries — the
QtGraphs PointRenderer strokes through QQuickShape/QPainterPath, re-triangulating on the
CPU every update, which stalled on audio-rate curves. The LineSeries objects remain as pure
data carriers (the models still draw() into them; PlotCurve.source follows the series'
update() signal) but are never added to the graph; only the ScatterSeries stay in the
GraphsView (interpolation None + axis anchoring). PlotCurve items live in
PlotWidget.curveLayer (a clipped item tracking the plot area, above PlotAreaFill, below the
crosshair overlay) and map world coordinates with the same visible-window transform as the
cursors. Offscreen stretches are culled by per-segment X-interval overlap, so zoomed series cost
the visible slice; NaNs break the ribbon into runs (true gaps). MultiPlot instantiates one
PlotCurve per curve with an inline carrier (source: LineSeries {}), and its onUiTimeout
loop draws the carriers from the _curves Instantiator (graph seriesList now only holds
scatter).
Dashboard::plotTimeRange (seconds, default 10, 1 ms min) is the ring
window T; setPlotTimeRange rebuilds each TimeRing at the new capacity (configurePlot /
configureMultiPlot in Dashboard.cpp). Per-project, mirroring pointCount: in ProjectFile
it lives in the .ssproj (ProjectModel::plotTimeRange / Keys::PlotTimeRange, edited in the
project overview); elsewhere it's QSettings Dashboard/PlotTimeRange (edited in Settings).
Dashboard syncs m_plotTimeRange from the project on operationModeChanged and persists to
QSettings only outside ProjectFile. Both UI controls are an oscilloscope-style editable
SpinBox snapping typed input to a 1 ms..300 s ladder. API: dashboard.setTimeRange{seconds} /
dashboard.getTimeRange (alias project.dashboard.setTimeRange); the old setPoints/getPoints
commands were removed with the rename. The legacy points (kDefaultPlotPoints = 1000) still
sizes the raw rings for dataset-X / FFT / GPS / 3D; the "Points" controls were removed from the UI.
syncHistoryToTimeRange sets m_historySize = round(plotTimeRange * fps) (clamped 16..4096) on plotTimeRangeChanged / fpsChanged and at
construction, so its time axis (historySize / fps) reads the Time Range. fps is the row cadence
(one row per dashboard updated tick), not the sample rate; sub-second ranges clamp to 16 rows.
Hides its X section for time plots (timeAxis from the widget model); the manual
X min/max is meaningless when X is the Time Range. Y range stays editable.
Widgets::PlotAreaFill)Driven via PlotWidget.qml's areaFillSource /
areaFillBaseline / areaFillColor: the curve is rasterized into per-pixel-column min/max
envelopes (one O(points) pass; segments bridge every column they cross, clipped to the visible
window with a kMaxBridgedColumns budget for non-monotonic curves), then emitted as one
degenerate-stitched GPU triangle strip with a peak quad above and a valley quad below the baseline
per column. Geometry is O(item width), independent of point density — a zoomed audio-rate series
costs the same as a sparse one — and columns are watertight (the old per-point strip self-crossed
into bowtie quads at every baseline crossing, washing out dense bipolar fills). Per-vertex alpha:
0.12 at the baseline, 0.50 at the data's per-sign extreme (gradient anchors to the data, not the
axis range); the fill color is a saturation-deepened (1-(1-s)^2, hue-preserving) variant of the
curve color so pastel themes stay vivid. Overlaid on the GraphsView plot area, tracking
xVisibleMin/yVisibleMin under zoom/pan. It replaced the QtGraphs AreaSeries (whose per-tick
CPU shape re-triangulation stalled audio-rate curves) and the bipolar drawClamped split series.
Baseline rules: Plot = 0 when bipolar, maxY when all-negative (inverted mountain), else minY;
FFT always uses minY (floor). NaN samples break the column run and leave a real gap. The fill
renders above the curve stroke and below the crosshair overlay; it follows the curve series'
update() signal, so paused plots freeze it for free.
Oscilloscope sweep for time-axis Plot/MultiPlot. DSP::SweepEngine
(DSP.h) owns a front/back decimating TimeRing per curve; advance(now, trigValue) runs on the hotpath
(alloc-free), detects a level+edge crossing (interpolated t0), honors holdoff + Auto/Normal/Single, and
swaps back->front when sweepTime > activeWindow(). The capture width is activeWindow() =
timebaseSec when set (0 < it < windowSec) else the full windowSec. Completion re-arms and falls
through in the same advance call so the next trigger starts immediately, refreshing continuously rather
than stalling a full window; in Auto, the free-run timeout is activeWindow() (not windowSec). Each sweep
is phase-locked to its interpolated t0, so successive completed sweeps overlay as a stationary trace.
display(curve) is threshold-gated on kLiveWindowSec (0.1s): short windows return the completed front
(frozen, phase-locked overlay), but windows wider than the threshold return the live back while sweeping
so long ranges grow left-to-right in real time instead of stalling a multi-second hold; before the first
completion it always returns back. The Dashboard holds m_plotSweep/m_multiplotSweep (keyed by widget index),
fed from TimePush::sweep/MultiPush::sweep in updateLineSeries/updateDataSeries via the
feedSweep/feedMultiSweep lambdas; engines are created in configureLineSeries/configureMultiLineSeries
for time plots and the config (including timebaseSec) survives a Time-Range rebuild via
restorePlotSweepConfig/restoreMultiplotSweepConfig. When enabled the widget axis is [0, activeWindow]
(vs rolling [-T, 0]) and updateData renders the held sweep through DSP::downsampleWindowAbsolute
(no newest-rebase). Config lives per-widget in widgetSettings
(sweepEnabled/sweepMode/triggerEdge/triggerLevel/holdoff/sweepTimebase(+triggerSource for
MultiPlot); sweepTimebase is ms, 0 = match time range). QML wiring is a Pro-gated toolbar toggle +
TriggerDialog.qml (with the optional "Timebase (ms)" field), and the trigger-level line drawn in
PlotWidget.qml (sweepMode/triggerLevel). Setters are runtime-gated to FeatureTier >= Trial. SweepMode/
TriggerEdge enums live in SerialStudio.h.
app/src/UI/Widgets/Output/, QML in app/qml/Widgets/Dashboard/Output/:
Button/Toggle/Slider/TextField/Panel sharing Base. User JS converts UI state → device
bytes (app/rcc/scripts/output/*.js); OutputCodeEditor edits; TransmitTestDialog
previews. Protocol helpers (CRC, NMEA, Modbus, SLCAN, GRBL, GCode, SCPI, binary packet)
injected into the engine. Gated FeatureTier >= Pro
(None=0, Hobbyist=1, Trial=2, Pro=3, Enterprise=4).
UI::Taskbar)app/qml/MainWindow/Taskbar/: user-defined dashboard tabs.
Persisted under "workspaces". Workspace IDs ≥ 1000, group IDs < 1000.
Taskbar::deleteWorkspace(id) branches on the threshold — don't cross-wire. Edits stage
in memory + setModified(true); no autosave.
UI/Widgets/Waterfall.h/.cpp: per-dataset Pro widget
reusing the dataset's FFT settings. Class IS the painted item (QQuickPaintedItem).
Toggle via DatasetWaterfall = 0b01000000; persists as Keys::Waterfall (omit when false).
Keys::WaterfallYAxis non-zero → Campbell mode: rows placed by another dataset's
value (e.g. RPM) instead of time. commercialCfg() flags any project using waterfall.