886 lines
34 KiB
C++
886 lines
34 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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());
|
|
|
|
const CaptureFrame* cap = hub->capture();
|
|
const bool trigView = (cap != nullptr) && gv->trigView;
|
|
auto& zc = hub->zoomCache(w_->plotIdx_);
|
|
auto& hc = hub->histZoomCache(w_->plotIdx_);
|
|
const bool paused = w_->paused_;
|
|
bool& live = w_->live_;
|
|
|
|
/* ── 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 = !trigView && live && !paused &&
|
|
gv->windowSec <= kLiveHiResMaxWin && zc.valid &&
|
|
(zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0;
|
|
|
|
const bool useZoomData = !trigView && !paused && zc.valid &&
|
|
(liveHiRes ||
|
|
(!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9));
|
|
|
|
bool useHistData = !trigView && !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 (trigView) {
|
|
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] - cap->trigTime);
|
|
vStore[si].push_back(cs.v[i]);
|
|
}
|
|
break;
|
|
}
|
|
} 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]);
|
|
}
|
|
|
|
/* ── X range ────────────────────────────────────────────────────────── */
|
|
double xMin, xMax;
|
|
if (trigView) {
|
|
if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; }
|
|
else { xMin = -cap->preSec; xMax = cap->postSec; }
|
|
} 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 */
|
|
const auto& av = (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
|
|
w_->activeSlot_ < (int)slots.size())
|
|
? slots[w_->activeSlot_].vs : VScale();
|
|
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 (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
|
|
w_->activeSlot_ < (int)slots.size()) {
|
|
double rawVal = av.resolvedOffset + (d - av.screenPos) * av.resolvedDiv;
|
|
lbl = fmtVal(rawVal);
|
|
} 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 = trigView ? 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 {
|
|
vNorm.resize(nOut);
|
|
for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], a.vs);
|
|
}
|
|
|
|
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 (trigView) {
|
|
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 CaptureFrame* cap = hub->capture();
|
|
const bool trigView = (cap != nullptr) && gv->trigView;
|
|
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 (trigView && !w_->trigZoomed_) {
|
|
w_->setStoredX(-cap->preSec, cap->postSec);
|
|
w_->trigZoomed_ = true;
|
|
}
|
|
};
|
|
auto xZoomStored = [&](double f) {
|
|
if (trigView) 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);
|
|
};
|
|
|
|
auto makeManual = [&](PlotAssignment& a) {
|
|
if (a.vs.mode != 2) {
|
|
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
|
|
a.vs.offset = a.vs.resolvedOffset;
|
|
a.vs.mode = 2;
|
|
}
|
|
};
|
|
|
|
if (ctrl) {
|
|
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
|
|
else xZoomStored(factor);
|
|
} else if (shift) {
|
|
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) {
|
|
auto& a = slots[w_->activeSlot_];
|
|
makeManual(a);
|
|
a.vs.screenPos += (dy > 0) ? 0.5 : -0.5;
|
|
}
|
|
} else {
|
|
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) {
|
|
auto& a = slots[w_->activeSlot_];
|
|
makeManual(a);
|
|
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
|
|
} else {
|
|
if (!trigView && 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 CaptureFrame* cap = hub->capture();
|
|
const bool trigView = (cap != nullptr) && gv->trigView;
|
|
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 (trigView && !w_->trigZoomed_) {
|
|
w_->setStoredX(-cap->preSec, cap->postSec);
|
|
w_->trigZoomed_ = true;
|
|
}
|
|
if (!trigView && 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 CaptureFrame* cap = hub->capture();
|
|
const bool trigView = (cap != nullptr) && gv->trigView;
|
|
const double now = nowSec();
|
|
|
|
if (!trigView && !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);
|
|
b->setText(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 / D / M */
|
|
const char* vl[3] = {"N", "D", "M"};
|
|
for (int vm = 0; vm < 3; vm++) {
|
|
auto* vb = new QToolButton(header_);
|
|
vb->setText(vl[vm]);
|
|
vb->setCheckable(true);
|
|
vb->setChecked(vMode_ == vm);
|
|
connect(vb, &QToolButton::clicked, this, [this, vm]() {
|
|
vMode_ = vm; rebuildHeader(); canvas_->update();
|
|
});
|
|
headerLay_->addWidget(vb);
|
|
}
|
|
|
|
headerLay_->addStretch(1);
|
|
}
|
|
|
|
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(); });
|
|
}
|
|
|
|
m.addSeparator();
|
|
QMenu* vs = m.addMenu("V-scale");
|
|
const char* modes[] = {"Auto", "Range", "Manual"};
|
|
for (int mm = 0; mm < 3; mm++) {
|
|
QAction* act = vs->addAction(modes[mm]);
|
|
act->setCheckable(true); act->setChecked(a.vs.mode == mm);
|
|
connect(act, &QAction::triggered, this, [&, mm]() { a.vs.mode = mm; rebuildHeader(); canvas_->update(); });
|
|
}
|
|
vs->addSeparator();
|
|
vs->addAction("Manual V/div…", [&]() {
|
|
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
|
|
a.vs.mode==2?a.vs.divValue:a.vs.resolvedDiv, -1e12, 1e12, 6, &ok);
|
|
if (ok) { a.vs.divValue = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
|
|
});
|
|
vs->addAction("Offset…", [&]() {
|
|
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
|
|
a.vs.mode==2?a.vs.offset:a.vs.resolvedOffset, -1e12, 1e12, 6, &ok);
|
|
if (ok) { a.vs.offset = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
|
|
});
|
|
vs->addAction("Position (div)…", [&]() {
|
|
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
|
|
a.vs.screenPos, -8, 8, 2, &ok);
|
|
if (ok) { a.vs.screenPos = v; canvas_->update(); }
|
|
});
|
|
|
|
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 */
|