Files

1042 lines
40 KiB
C++

/**
* @file PlotWidget.cpp
*/
#include "PlotWidget.h"
#include "Hub.h"
#include "Theme.h"
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QToolButton>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QWheelEvent>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QMimeData>
#include <QMenu>
#include <QColorDialog>
#include <QInputDialog>
#include <QDateTime>
#include <QPolygonF>
#include <algorithm>
#include <cmath>
#include <cstdio>
using namespace StreamHubClient;
namespace shq {
static const char* kMimeSignal = "application/x-shq-signal";
static const size_t kMaxPush = 2400;
static const double kLiveHiResMaxWin = 600.0;
static double nowSec() {
return QDateTime::currentMSecsSinceEpoch() / 1000.0;
}
/* ── ported math helpers ─────────────────────────────────────────────────── */
static QString fmtVal(double v) {
if (!std::isfinite(v)) { return QStringLiteral("?"); }
char buf[32];
double a = std::fabs(v);
if (v == 0.0) std::snprintf(buf, sizeof(buf), "0");
else if (a >= 1e4 || a < 1e-3) std::snprintf(buf, sizeof(buf), "%.2e", v);
else std::snprintf(buf, sizeof(buf), "%.3g", v);
return QString::fromLatin1(buf);
}
static void resolveVScale(PlotAssignment& a, const SignalView& sig,
const std::vector<double>& rawV) {
if (a.vs.mode == 1) {
double rmin = sig.meta.rangeMin, rmax = sig.meta.rangeMax;
if (rmax > rmin) {
a.vs.resolvedDiv = std::max((rmax - rmin) / 8.0, 1e-30);
a.vs.resolvedOffset = (rmin + rmax) / 2.0;
return;
}
}
if (a.vs.mode == 2) {
a.vs.resolvedDiv = std::max(a.vs.divValue, 1e-30);
a.vs.resolvedOffset = a.vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
for (double v : rawV) { if (v < mn) mn = v; if (v > mx) mx = v; }
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
a.vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
a.vs.resolvedOffset = (mx + mn) / 2.0;
}
static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
}
/* Resolve the one scale every trace shares in unified mode: same rules as the
* per-signal version applied to the union of the plot — range takes the union
* of the declared ranges, auto fits the union of the data. */
static void resolveUnifiedVScale(VScale& vs,
const std::vector<PlotAssignment>& slots,
const std::vector<Source>& sources,
const std::vector<std::vector<double> >& vStore) {
if (vs.mode == 2) {
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
vs.resolvedOffset = vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
if (vs.mode == 1) {
for (const auto& a : slots) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 ||
a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta;
if (!(m.rangeMax > m.rangeMin)) continue;
if (m.rangeMin < mn) mn = m.rangeMin;
if (m.rangeMax > mx) mx = m.rangeMax;
}
if (mx > mn) {
vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30);
vs.resolvedOffset = (mn + mx) / 2.0;
return;
}
mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */
}
for (const auto& vv : vStore) {
for (double v : vv) {
if (!std::isfinite(v)) continue;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
}
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
vs.resolvedOffset = (mx + mn) / 2.0;
}
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
mn = 1e300; mx = -1e300;
for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } }
return mx >= mn;
}
static void bandNormalize(const std::vector<double>& raw, std::vector<double>& out,
int ki, int n, bool quantize) {
double bandH = 8.0 / std::max(n, 1);
double centerY = 4.0 - (ki + 0.5) * bandH;
double hi = centerY + bandH * 0.35;
double lo = centerY - bandH * 0.35;
double mn, mx;
bool haveMM = dataMinMax(raw, mn, mx);
out.resize(raw.size());
if (quantize) {
double thr = haveMM ? (mn + mx) / 2.0 : 0.5;
for (size_t i = 0; i < raw.size(); i++) {
double v = raw[i];
out[i] = !std::isfinite(v) ? NAN : (v >= thr ? hi : lo);
}
} else {
if (!haveMM) { mn = 0.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
double range = mx - mn, bandRange = hi - lo;
for (size_t i = 0; i < raw.size(); i++) {
double v = raw[i];
out[i] = !std::isfinite(v) ? NAN : lo + (v - mn) / range * bandRange;
}
}
}
static double sampleAt(const std::vector<double>& t, const std::vector<double>& v,
double x) {
if (t.empty() || v.size() != t.size()) { return NAN; }
auto it = std::lower_bound(t.begin(), t.end(), x);
size_t i = static_cast<size_t>(it - t.begin());
if (i >= t.size()) { i = t.size() - 1; }
else if (i > 0 && (x - t[i-1]) < (t[i] - x)) { i--; }
return v[i];
}
/*===========================================================================*/
/* PlotCanvas */
/*===========================================================================*/
class PlotCanvas : public QWidget {
public:
explicit PlotCanvas(PlotWidget* w) : QWidget(w), w_(w) {
setMinimumSize(80, 60);
setAcceptDrops(true);
setMouseTracking(true);
}
protected:
void paintEvent(QPaintEvent*) override;
void wheelEvent(QWheelEvent* e) override;
void mousePressEvent(QMouseEvent* e) override;
void mouseMoveEvent(QMouseEvent* e) override;
void mouseReleaseEvent(QMouseEvent* e) override;
void dragEnterEvent(QDragEnterEvent* e) override;
void dropEvent(QDropEvent* e) override;
private:
/* geometry of the plot drawing rect */
QRectF plotRect() const {
const double L = 56, R = 12, T = 8, B = 24;
return QRectF(L, T, std::max(10.0, width() - L - R),
std::max(10.0, height() - T - B));
}
double xToPx(double t, double xMin, double xMax, const QRectF& r) const {
if (xMax <= xMin) return r.left();
return r.left() + (t - xMin) / (xMax - xMin) * r.width();
}
double pxToX(double px, double xMin, double xMax, const QRectF& r) const {
if (r.width() <= 0) return xMin;
return xMin + (px - r.left()) / r.width() * (xMax - xMin);
}
double yToPx(double yDiv, const QRectF& r) const {
return r.top() + (4.05 - yDiv) / 8.10 * r.height();
}
void drawMarker(QPainter& p, double cx, double cy, int marker, double sz);
PlotWidget* w_;
/* interaction state */
bool panning_ = false;
int dragCursor_ = 0; /* 0 none, 1 A, 2 B */
QPoint lastPos_;
double curXMin_ = 0, curXMax_ = 1; /* last painted X range (for hit-test) */
};
void PlotCanvas::drawMarker(QPainter& p, double cx, double cy, int marker, double sz) {
double h = sz;
switch (marker) {
case 0: p.drawEllipse(QPointF(cx, cy), h, h); break; /* circle */
case 1: p.drawRect(QRectF(cx-h, cy-h, 2*h, 2*h)); break; /* square */
case 2: { QPolygonF d; d << QPointF(cx,cy-h)<<QPointF(cx+h,cy)
<<QPointF(cx,cy+h)<<QPointF(cx-h,cy); p.drawPolygon(d); } break;
case 3: { QPolygonF u; u<<QPointF(cx,cy-h)<<QPointF(cx+h,cy+h)
<<QPointF(cx-h,cy+h); p.drawPolygon(u); } break; /* up */
case 4: { QPolygonF d; d<<QPointF(cx,cy+h)<<QPointF(cx+h,cy-h)
<<QPointF(cx-h,cy-h); p.drawPolygon(d); } break; /* down */
case 7: p.drawLine(QPointF(cx-h,cy-h),QPointF(cx+h,cy+h));
p.drawLine(QPointF(cx-h,cy+h),QPointF(cx+h,cy-h)); break;/* cross */
case 8: p.drawLine(QPointF(cx-h,cy),QPointF(cx+h,cy));
p.drawLine(QPointF(cx,cy-h),QPointF(cx,cy+h)); break; /* plus */
default: p.drawEllipse(QPointF(cx, cy), h, h); break;
}
}
/** @brief What the plot renders on the trigger-relative axis, if anything. */
struct TrigView {
bool rel = false; /* render against t - trig instead of wall clock */
bool fromCap = false; /* data comes from the capture frame, not the ring */
double trigT = 0.0;
double preS = 0.0;
double postS = 0.0;
};
/* Two ways to end up in trigger-relative time. Either a v2 capture frame has
* arrived, or a trigger has fired and its window is still filling. In the
* second case the hub sends nothing until the whole window has been produced —
* several seconds for a long window at a high rate — so the trace is drawn from
* the local rings onto the final axis, growing left to right. Filling wins
* over the last capture: once a new trigger fires the old waveform is history.
* A capture latches its own pre/post at fire time, so later edits in the
* trigger bar must not move the axis of a finished capture. */
static TrigView resolveTrigView(Hub* hub, const GlobalView* gv, bool paused) {
TrigView tv;
if (!gv->trigView) { return tv; }
const TriggerCfgState& t = hub->trigger();
if (!paused && t.status == "collecting" && t.hasTrigTime) {
tv.rel = true;
tv.trigT = t.trigTime;
/* Prefer the window the hub latched at fire time; the local config is
* only a fallback for hubs that do not report it, and may have been
* edited since the trigger fired. */
tv.preS = t.hasFiredWin ? t.firedPreS
: t.windowSec * t.prePercent * 0.01;
tv.postS = t.hasFiredWin ? t.firedPostS : t.windowSec - tv.preS;
return tv;
}
const CaptureFrame* cap = hub->capture();
if (cap != nullptr) {
tv.rel = true;
tv.fromCap = true;
tv.trigT = cap->trigTime;
tv.preS = cap->preSec;
tv.postS = cap->postSec;
}
return tv;
}
void PlotCanvas::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
Hub* hub = w_->hub_;
GlobalView* gv = w_->gv_;
auto& slots = w_->slots_;
auto& sources = hub->sources();
const QRectF r = plotRect();
const double wallNow = nowSec();
/* background + border */
p.fillRect(rect(), col::base());
p.fillRect(r, col::crust());
auto& zc = hub->zoomCache(w_->plotIdx_);
auto& hc = hub->histZoomCache(w_->plotIdx_);
const bool paused = w_->paused_;
bool& live = w_->live_;
const TrigView tv = resolveTrigView(hub, gv, paused);
const CaptureFrame* cap = hub->capture();
/* ── pause snapshot ─────────────────────────────────────────────────── */
auto& snap = w_->snap_;
if (paused) {
if (!snap.valid) {
snap.keys.clear(); snap.t.clear(); snap.v.clear();
for (const auto& a : slots) {
std::vector<double> tt, vv;
if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size() &&
a.signalIdx >= 0 &&
a.signalIdx < (int)sources[a.sourceIdx].signals.size()) {
sources[a.sourceIdx].signals[a.signalIdx]
.buf.readLast((size_t)hub->maxPoints(), tt, vv);
}
snap.keys.push_back(hub->slotKey(a));
snap.t.push_back(std::move(tt));
snap.v.push_back(std::move(vv));
}
snap.valid = true;
if (live) { w_->initPlotX(wallNow); live = false; }
}
} else if (snap.valid) {
snap.valid = false;
}
/* ── gather data per slot ───────────────────────────────────────────── */
std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size());
const bool liveHiRes = !tv.rel && live && !paused &&
gv->windowSec <= kLiveHiResMaxWin && zc.valid &&
(zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0;
const bool useZoomData = !tv.rel && !paused && zc.valid &&
(liveHiRes ||
(!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9));
bool useHistData = !tv.rel && !paused && !live && hc.valid &&
hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9;
if (useHistData) {
bool any = false;
for (const auto& hs : hc.pts) { if (!hs.t.empty()) { any = true; break; } }
if (!any) { useHistData = false; }
}
auto readBase = [&](size_t si, const SignalView& sig, const std::string& key) {
if (paused && snap.valid) {
for (size_t k = 0; k < snap.keys.size(); k++) {
if (snap.keys[k] == key) { tStore[si] = snap.t[k]; vStore[si] = snap.v[k]; return; }
}
}
sig.buf.readLast((size_t)hub->maxPoints(), tStore[si], vStore[si]);
};
for (size_t si = 0; si < slots.size(); si++) {
auto& a = slots[si];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 || a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
const std::string key = hub->slotKey(a);
if (tv.fromCap) {
for (const auto& cs : cap->signals) {
if (cs.key != key) continue;
size_t n = std::min(cs.t.size(), cs.v.size());
tStore[si].reserve(n); vStore[si].reserve(n);
for (size_t i = 0; i < n; i++) {
tStore[si].push_back(cs.t[i] - tv.trigT);
vStore[si].push_back(cs.v[i]);
}
break;
}
} else if (tv.rel) {
/* Filling: local ring, clipped to the (absolute) trigger window and
* shifted onto the trigger-relative axis. */
sig.buf.readRange(tv.trigT - tv.preS, tv.trigT + tv.postS,
tStore[si], vStore[si]);
for (size_t i = 0; i < tStore[si].size(); i++) {
tStore[si][i] -= tv.trigT;
}
} else if (useZoomData) {
bool found = false;
for (const auto& zs : zc.pts) {
if (zs.name != key) continue;
tStore[si] = zs.t; vStore[si] = zs.v; found = true; break;
}
if (!found) readBase(si, sig, key);
} else if (useHistData) {
bool found = false;
for (const auto& hs : hc.pts) {
if (hs.name != key) continue;
tStore[si] = hs.t; vStore[si] = hs.v; found = true; break;
}
if (!found) readBase(si, sig, key);
} else {
readBase(si, sig, key);
}
resolveVScale(a, sig, vStore[si]);
}
if (w_->vMode_ == 3) {
resolveUnifiedVScale(w_->uniVS_, slots, sources, vStore);
}
/* ── X range ────────────────────────────────────────────────────────── */
double xMin, xMax;
if (tv.rel) {
if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; }
/* Full window from the start, even while filling: a trace growing into
* a fixed axis reads as progress, whereas an axis that grows with the
* data shifts the whole trace every frame. */
else { xMin = -tv.preS; xMax = tv.postS; }
} else if (live && !paused) {
if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; }
else { xMax = wallNow; xMin = wallNow - gv->windowSec; }
} else {
xMin = w_->plotXMin_; xMax = w_->plotXMax_;
}
if (xMax <= xMin) { xMax = xMin + 1.0; }
curXMin_ = xMin; curXMax_ = xMax;
/* ── grid + ticks ───────────────────────────────────────────────────── */
p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0));
/* Y grid: 9 division lines */
/* Which scale labels the axis: the active signal's in normal mode, the one
* the whole plot shares in unified mode (where nothing has to be selected).
* Banded modes have no single scale, so they keep the plain division numbers. */
const VScale* axisVS = nullptr;
if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
w_->activeSlot_ < (int)slots.size()) {
axisVS = &slots[w_->activeSlot_].vs;
} else if (w_->vMode_ == 3) {
axisVS = &w_->uniVS_;
}
p.setFont(QFont(font().family(), 8));
for (int d = -4; d <= 4; d++) {
double y = yToPx(d, r);
p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0));
p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y));
QString lbl;
if (axisVS != nullptr) {
lbl = fmtVal(axisVS->resolvedOffset +
(d - axisVS->screenPos) * axisVS->resolvedDiv);
} else {
lbl = QString::number(d);
}
p.setPen(QColor(0xa6,0xad,0xc8));
p.drawText(QRectF(0, y-8, r.left()-4, 16),
Qt::AlignRight|Qt::AlignVCenter, lbl);
}
/* X grid: 11 ticks */
for (int t = 0; t <= 10; t++) {
double xv = xMin + (xMax - xMin) * t / 10.0;
double x = xToPx(xv, xMin, xMax, r);
p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
p.setPen(QColor(0xa6,0xad,0xc8));
QString xl = tv.rel ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3);
int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter))
| Qt::AlignTop;
p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl);
}
p.setPen(QPen(col::surface0(), 1.0));
p.drawRect(r);
/* clip drawing to the plot rect */
p.save();
p.setClipRect(r);
/* ── clip data to visible window, LTTB, normalize, draw ─────────────── */
int nTraces = 0;
for (const auto& a : slots) {
if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) nTraces++;
}
std::vector<double> tDec, vDec, vNorm;
int ki = 0;
for (size_t si = 0; si < slots.size(); si++) {
auto& a = slots[si];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 || a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
int myKi = ki++;
if (!sig.visible) continue;
auto tv = tStore[si];
auto vv = vStore[si];
if (tv.empty()) continue;
/* clip to [xMin,xMax] keeping one sample each side */
auto lo = std::lower_bound(tv.begin(), tv.end(), xMin);
auto hi = std::upper_bound(lo, tv.end(), xMax);
if (lo != tv.begin()) --lo;
if (hi != tv.end()) ++hi;
size_t i0 = (size_t)(lo - tv.begin());
size_t i1 = (size_t)(hi - tv.begin());
if (i0 > 0 || i1 < tv.size()) {
tv = std::vector<double>(tv.begin()+i0, tv.begin()+i1);
vv = std::vector<double>(vv.begin()+i0, vv.begin()+i1);
}
if (tv.empty()) continue;
size_t nOut = LTTBDecimate(tv, vv, tDec, vDec, kMaxPush);
if (nOut == 0) continue;
if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true);
else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
else {
/* unified shares one scale, normal gives each trace its own */
const VScale& nvs = (w_->vMode_ == 3) ? w_->uniVS_ : a.vs;
vNorm.resize(nOut);
for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], nvs);
}
QColor c = sig.color;
p.setPen(QPen(c, sig.lineWidth));
QPolygonF poly;
poly.reserve((int)nOut);
for (size_t k = 0; k < nOut; k++) {
if (!std::isfinite(vNorm[k])) {
if (poly.size() > 1) p.drawPolyline(poly);
poly.clear();
continue;
}
poly << QPointF(xToPx(tDec[k], xMin, xMax, r), yToPx(vNorm[k], r));
}
if (poly.size() > 1) p.drawPolyline(poly);
else if (poly.size() == 1) p.drawPoint(poly.front());
if (sig.marker >= 0) {
p.setBrush(c);
for (const QPointF& pt : poly) drawMarker(p, pt.x(), pt.y(), sig.marker, 2.5);
p.setBrush(Qt::NoBrush);
}
}
/* trigger instant marker at t=0 */
if (tv.rel) {
double x = xToPx(0.0, xMin, xMax, r);
p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
}
/* cursors A/B */
if (gv->cursorsOn) {
bool aBeyond = (gv->cursorA < xMin || gv->cursorA > xMax);
bool bBeyond = (gv->cursorB < xMin || gv->cursorB > xMax);
if (aBeyond && bBeyond) {
gv->cursorA = xMin + (xMax-xMin)*0.25;
gv->cursorB = xMin + (xMax-xMin)*0.75;
}
double xa = xToPx(gv->cursorA, xMin, xMax, r);
double xb = xToPx(gv->cursorB, xMin, xMax, r);
p.setPen(QPen(col::peach(), 1.2));
p.drawLine(QPointF(xa, r.top()), QPointF(xa, r.bottom()));
p.drawText(QPointF(xa+2, r.top()+12), "A");
p.setPen(QPen(col::mauve(), 1.2));
p.drawLine(QPointF(xb, r.top()), QPointF(xb, r.bottom()));
p.drawText(QPointF(xb+2, r.top()+12), "B");
}
p.restore();
/* update cursor readout label + HIST flag handled in PlotWidget::tick */
w_->cursorLbl_->setVisible(gv->cursorsOn);
if (gv->cursorsOn) {
double dT = gv->cursorB - gv->cursorA;
QString s = QString("dT=%1s 1/dT=%2Hz")
.arg(fmtVal(dT))
.arg(fmtVal(dT != 0.0 ? 1.0/std::fabs(dT) : 0.0));
for (size_t si = 0; si < slots.size(); si++) {
auto& a = slots[si];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (tStore[si].empty()) continue;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
double vA = sampleAt(tStore[si], vStore[si], gv->cursorA);
double vB = sampleAt(tStore[si], vStore[si], gv->cursorB);
s += QString(" %1: A=%2 B=%3 d=%4")
.arg(QString::fromStdString(sig.meta.name))
.arg(fmtVal(vA)).arg(fmtVal(vB)).arg(fmtVal(vB-vA));
}
w_->cursorLbl_->setText(s);
}
}
/* ── interaction ─────────────────────────────────────────────────────────── */
void PlotCanvas::wheelEvent(QWheelEvent* e) {
Hub* hub = w_->hub_;
GlobalView* gv = w_->gv_;
auto& slots = w_->slots_;
const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
bool& live = w_->live_;
double dy = e->angleDelta().y();
if (dy == 0.0) { e->ignore(); return; }
const double factor = (dy > 0) ? 0.8 : 1.25;
const bool ctrl = e->modifiers() & Qt::ControlModifier;
const bool shift = e->modifiers() & Qt::ShiftModifier;
const double now = nowSec();
auto enterTrigZoom = [&]() {
if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true;
}
};
auto xZoomStored = [&](double f) {
if (tv.rel) enterTrigZoom();
if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; }
double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5;
double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f;
w_->setStoredX(cx - half, cx + half);
};
/* Seed manual from the resolved values so the gesture sticks. */
auto makeManual = [&](VScale& vs) {
if (vs.mode != 2) {
vs.divValue = std::max(vs.resolvedDiv, 1e-30);
vs.offset = vs.resolvedOffset;
vs.mode = 2;
}
};
/* Scroll adjusts the scale the axis is labelled with: the active signal's in
* normal mode, the plot's shared one in unified mode (nothing to select). */
VScale* wheelVS = nullptr;
if (w_->vMode_ == 3) {
wheelVS = &w_->uniVS_;
} else if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) {
wheelVS = &slots[w_->activeSlot_].vs;
}
if (ctrl) {
if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor);
} else if (shift) {
if (wheelVS != nullptr) {
makeManual(*wheelVS);
wheelVS->screenPos += (dy > 0) ? 0.5 : -0.5;
}
} else {
if (wheelVS != nullptr) {
makeManual(*wheelVS);
wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
} else {
if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor);
}
}
update();
e->accept();
}
void PlotCanvas::mousePressEvent(QMouseEvent* e) {
GlobalView* gv = w_->gv_;
const QRectF r = plotRect();
lastPos_ = e->pos();
if (e->button() == Qt::LeftButton && gv->cursorsOn) {
double xa = xToPx(gv->cursorA, curXMin_, curXMax_, r);
double xb = xToPx(gv->cursorB, curXMin_, curXMax_, r);
if (std::fabs(e->pos().x() - xa) < 6) { dragCursor_ = 1; return; }
if (std::fabs(e->pos().x() - xb) < 6) { dragCursor_ = 2; return; }
}
if (e->button() == Qt::RightButton) { panning_ = true; }
}
void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
GlobalView* gv = w_->gv_;
Hub* hub = w_->hub_;
const QRectF r = plotRect();
const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
bool& live = w_->live_;
if (dragCursor_ != 0) {
double x = pxToX(e->pos().x(), curXMin_, curXMax_, r);
if (dragCursor_ == 1) gv->cursorA = x; else gv->cursorB = x;
update();
return;
}
if (panning_) {
if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true;
}
if (!tv.rel && live) { w_->initPlotX(nowSec()); live = false; }
double dxPix = e->pos().x() - lastPos_.x();
lastPos_ = e->pos();
double xRange = w_->plotXMax_ - w_->plotXMin_;
if (r.width() > 0) {
double dt = -dxPix / r.width() * xRange;
w_->setStoredX(w_->plotXMin_ + dt, w_->plotXMax_ + dt);
}
update();
}
}
void PlotCanvas::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::RightButton) panning_ = false;
if (e->button() == Qt::LeftButton) dragCursor_ = 0;
}
void PlotCanvas::dragEnterEvent(QDragEnterEvent* e) {
if (e->mimeData()->hasFormat(kMimeSignal)) e->acceptProposedAction();
}
void PlotCanvas::dropEvent(QDropEvent* e) {
QByteArray d = e->mimeData()->data(kMimeSignal);
if (d.size() >= (int)(2*sizeof(qint32))) {
const qint32* p = reinterpret_cast<const qint32*>(d.constData());
w_->addAssignment(p[0], p[1]);
e->acceptProposedAction();
}
}
/*===========================================================================*/
/* PlotWidget */
/*===========================================================================*/
PlotWidget::PlotWidget(Hub* hub, GlobalView* gv, int plotIdx, QWidget* parent)
: QWidget(parent), hub_(hub), gv_(gv), plotIdx_(plotIdx) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(2, 2, 2, 2);
lay->setSpacing(2);
header_ = new QWidget(this);
headerLay_ = new QHBoxLayout(header_);
headerLay_->setContentsMargins(2, 0, 2, 0);
headerLay_->setSpacing(3);
lay->addWidget(header_);
cursorLbl_ = new QLabel(this);
cursorLbl_->setStyleSheet("color:#fab387; font-size:10px;");
cursorLbl_->setVisible(false);
lay->addWidget(cursorLbl_);
canvas_ = new PlotCanvas(this);
lay->addWidget(canvas_, 1);
connect(hub_, &Hub::zoomReceived, this, &PlotWidget::onZoomReceived);
connect(hub_, &Hub::historyZoomReceived, this, &PlotWidget::onHistoryZoomReceived);
connect(hub_, &Hub::captureReceived, this, &PlotWidget::onCaptureReceived);
rebuildHeader();
}
void PlotWidget::addAssignment(int sourceIdx, int signalIdx) {
for (const auto& a : slots_) {
if (a.sourceIdx == sourceIdx && a.signalIdx == signalIdx) return;
}
PlotAssignment pa; pa.sourceIdx = sourceIdx; pa.signalIdx = signalIdx;
slots_.push_back(pa);
rebuildHeader();
canvas_->update();
}
void PlotWidget::onModelChanged() { rebuildHeader(); }
void PlotWidget::onPauseChanged() {
paused_ = gv_->paused;
canvas_->update();
}
void PlotWidget::setLive(bool live) {
live_ = live;
if (live) { zoomHist_.clear(); hub_->zoomCache(plotIdx_).valid = false; }
rebuildHeader();
canvas_->update();
}
void PlotWidget::setStoredX(double mn, double mx) {
if (mx > mn + 1e-12) { plotXMin_ = mn; plotXMax_ = mx; }
}
void PlotWidget::initPlotX(double tMax) {
plotXMin_ = tMax - gv_->windowSec;
plotXMax_ = tMax;
}
void PlotWidget::pushZoomHist() {
if (!zoomHist_.empty() &&
zoomHist_.back().first == plotXMin_ &&
zoomHist_.back().second == plotXMax_) return;
if (zoomHist_.size() >= 64) zoomHist_.erase(zoomHist_.begin());
zoomHist_.emplace_back(plotXMin_, plotXMax_);
}
void PlotWidget::onZoomReceived(int p) { if (p == plotIdx_) canvas_->update(); }
void PlotWidget::onHistoryZoomReceived(int p) { if (p == plotIdx_) canvas_->update(); }
void PlotWidget::onCaptureReceived() {
trigZoomed_ = false; zoomHist_.clear(); canvas_->update();
}
/* ── periodic tick: repaint + throttled hi-res/history zoom requests ─────── */
void PlotWidget::tick() {
Hub* hub = hub_;
GlobalView* gv = gv_;
const TrigView tv = resolveTrigView(hub, gv, paused_);
const double now = nowSec();
if (!tv.rel && !paused_) {
std::string csv;
for (const auto& a : slots_) {
std::string k = hub->slotKey(a);
if (k.empty()) continue;
if (!csv.empty()) csv += ",";
csv += k;
}
auto& zc = hub->zoomCache(plotIdx_);
auto& hc = hub->histZoomCache(plotIdx_);
if (live_ && gv->windowSec <= kLiveHiResMaxWin) {
if (!csv.empty() && !zc.pending && now - lastLiveZoomMs_ > 0.25) {
hub->requestZoom(plotIdx_, now - gv->windowSec, now, csv);
lastLiveZoomMs_ = now;
}
} else if (!live_) {
double t0 = plotXMin_, t1 = plotXMax_;
if (t0 != lastT0_ || t1 != lastT1_) {
lastT0_ = t0; lastT1_ = t1; rangeChangedMs_ = now;
} else if (rangeChangedMs_ > 0.0 && now - rangeChangedMs_ > 0.35 && !csv.empty()) {
if (!zc.pending && !(zc.valid && zc.t0 == t0 && zc.t1 == t1))
hub->requestZoom(plotIdx_, t0, t1, csv);
const auto& hi = hub->historyInfo();
if (hi.enabled && !hc.pending && !(hc.valid && hc.t0 == t0 && hc.t1 == t1))
hub->requestHistoryZoom(plotIdx_, t0, t1, csv);
rangeChangedMs_ = 0.0;
}
}
}
canvas_->update();
}
/* ── header (badges + controls) ──────────────────────────────────────────── */
static void clearLayout(QLayout* lay) {
QLayoutItem* it;
while ((it = lay->takeAt(0)) != nullptr) {
if (it->widget()) it->widget()->deleteLater();
delete it;
}
}
void PlotWidget::rebuildHeader() {
clearLayout(headerLay_);
auto& sources = hub_->sources();
for (int i = 0; i < (int)slots_.size(); i++) {
auto& a = slots_[i];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 || a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
auto* b = new QToolButton(header_);
b->setCheckable(true);
b->setChecked(activeSlot_ == i);
/* In unified mode every badge would repeat the same div value, which
* the header's Y-Scale button already shows — so show just the name. */
b->setText(vMode_ == 3
? QString::fromStdString(sig.meta.name)
: QString("%1 %2/div")
.arg(QString::fromStdString(sig.meta.name))
.arg(fmtVal(a.vs.resolvedDiv)));
QColor c = sig.color;
QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b";
QColor bg = (activeSlot_ == i) ? col::blue() : c;
b->setStyleSheet(QString("QToolButton{background:%1;color:%2;border-radius:4px;"
"padding:1px 6px;font-size:10px;font-weight:bold;}")
.arg(bg.name()).arg(fg));
b->setContextMenuPolicy(Qt::CustomContextMenu);
connect(b, &QToolButton::clicked, this, [this, i]() {
activeSlot_ = (activeSlot_ == i) ? -1 : i;
rebuildHeader(); canvas_->update();
});
connect(b, &QWidget::customContextMenuRequested, this, [this, i, b](const QPoint& pos) {
showBadgeMenu(i, b->mapToGlobal(pos));
});
headerLay_->addWidget(b);
}
/* Live */
auto* liveBtn = new QToolButton(header_);
liveBtn->setText("Live");
liveBtn->setCheckable(true);
liveBtn->setChecked(live_);
connect(liveBtn, &QToolButton::clicked, this, [this]() {
setLive(true); paused_ = false;
});
headerLay_->addWidget(liveBtn);
if (!live_ || (gv_->trigView && trigZoomed_)) {
auto* back = new QToolButton(header_); back->setText("Back");
back->setEnabled(!zoomHist_.empty());
connect(back, &QToolButton::clicked, this, [this]() {
if (!zoomHist_.empty()) {
setStoredX(zoomHist_.back().first, zoomHist_.back().second);
zoomHist_.pop_back(); rebuildHeader(); canvas_->update();
}
});
headerLay_->addWidget(back);
auto* fit = new QToolButton(header_);
fit->setText(gv_->trigView ? "Reset" : "Fit");
connect(fit, &QToolButton::clicked, this, [this]() {
if (gv_->trigView) { trigZoomed_ = false; zoomHist_.clear(); }
else {
double mn = 1e300, mx = -1e300;
auto& sources = hub_->sources();
for (auto& a : slots_) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
std::vector<double> tt, vv;
sources[a.sourceIdx].signals[a.signalIdx].buf.readLast(hub_->maxPoints(), tt, vv);
if (!tt.empty()) { mn = std::min(mn, tt.front()); mx = std::max(mx, tt.back()); }
}
if (mx > mn) { pushZoomHist(); setStoredX(mn, mx); }
}
rebuildHeader(); canvas_->update();
});
headerLay_->addWidget(fit);
}
/* N / U / D / M */
const char* vl[4] = {"N", "U", "D", "M"};
const char* vtip[4] = {"Normal: one vertical scale per signal",
"Unified: one vertical scale shared by every signal",
"Digital", "Mixed"};
const int vmode[4] = {0, 3, 1, 2};
for (int i = 0; i < 4; i++) {
const int vm = vmode[i];
auto* vb = new QToolButton(header_);
vb->setText(vl[i]);
vb->setToolTip(vtip[i]);
vb->setCheckable(true);
vb->setChecked(vMode_ == vm);
connect(vb, &QToolButton::clicked, this, [this, vm]() {
vMode_ = vm; rebuildHeader(); canvas_->update();
});
headerLay_->addWidget(vb);
}
/* Unified mode's single scale belongs to the plot, not to any one signal,
* so it is edited from here rather than from a badge's context menu. */
if (vMode_ == 3) {
auto* yb = new QToolButton(header_);
yb->setText(QString("Y-Scale: %1/div").arg(fmtVal(uniVS_.resolvedDiv)));
yb->setToolTip("Vertical scale shared by every signal in this plot");
connect(yb, &QToolButton::clicked, this, [this, yb]() {
showUnifiedVScaleMenu(yb->mapToGlobal(QPoint(0, yb->height())));
});
headerLay_->addWidget(yb);
}
headerLay_->addStretch(1);
}
/** Populate @a vs with the Auto/Range/Manual entries driving @a evs. */
void PlotWidget::buildVScaleMenu(QMenu* vs, VScale& evs) {
const char* modes[] = {"Auto", "Range", "Manual"};
for (int mm = 0; mm < 3; mm++) {
QAction* act = vs->addAction(modes[mm]);
act->setCheckable(true); act->setChecked(evs.mode == mm);
connect(act, &QAction::triggered, this, [this, &evs, mm]() {
evs.mode = mm; rebuildHeader(); canvas_->update();
});
}
vs->addSeparator();
vs->addAction("Manual V/div…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
evs.mode==2?evs.divValue:evs.resolvedDiv, -1e12, 1e12, 6, &ok);
if (ok) { evs.divValue = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Offset…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
evs.mode==2?evs.offset:evs.resolvedOffset, -1e12, 1e12, 6, &ok);
if (ok) { evs.offset = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Position (div)…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
evs.screenPos, -8, 8, 2, &ok);
if (ok) { evs.screenPos = v; canvas_->update(); }
});
}
void PlotWidget::showUnifiedVScaleMenu(const QPoint& globalPos) {
QMenu m;
m.addAction("Y-Scale — all signals")->setEnabled(false);
m.addSeparator();
buildVScaleMenu(&m, uniVS_);
m.exec(globalPos);
}
void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
auto& sources = hub_->sources();
if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return;
auto& a = slots_[slotIdx];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) return;
auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
QMenu m;
m.addAction(QString::fromStdString(sig.meta.name))->setEnabled(false);
m.addSeparator();
m.addAction("Color…", [&]() {
QColor c = QColorDialog::getColor(sig.color, this, "Trace color");
if (c.isValid()) { sig.color = c; rebuildHeader(); canvas_->update(); }
});
QMenu* wm = m.addMenu("Width");
for (double w : {0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0}) {
wm->addAction(QString::number(w, 'f', 1), [&, w]() { sig.lineWidth = w; canvas_->update(); });
}
QMenu* mk = m.addMenu("Marker");
const char* mn[] = {"None","Circle","Square","Diamond","Up","Down","Cross","Plus"};
const int mv[] = {-1, 0, 1, 2, 3, 4, 7, 8};
for (int j = 0; j < 8; j++) {
int val = mv[j];
mk->addAction(mn[j], [&, val]() { sig.marker = val; canvas_->update(); });
}
if (vMode_ == 2) {
QAction* dg = m.addAction("Digital (mixed)");
dg->setCheckable(true); dg->setChecked(a.vs.digitalInMixed);
connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); });
}
/* In unified mode the plot has one scale for every trace, so it is edited
* from the header's Y-Scale button instead of from any one signal. */
if (vMode_ != 3) {
m.addSeparator();
buildVScaleMenu(m.addMenu("V-scale"), a.vs);
}
m.addSeparator();
m.addAction("Remove from plot", [&]() {
if (activeSlot_ == slotIdx) activeSlot_ = -1;
else if (activeSlot_ > slotIdx) activeSlot_--;
slots_.erase(slots_.begin() + slotIdx);
rebuildHeader(); canvas_->update();
});
m.exec(globalPos);
}
} /* namespace shq */