/** * @file PlotWidget.cpp */ #include "PlotWidget.h" #include "Hub.h" #include "Theme.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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& 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& 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& raw, std::vector& 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& t, const std::vector& 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(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)<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 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> 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 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(tv.begin()+i0, tv.begin()+i1); vv = std::vector(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(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 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 */