Implemented and fixed many issues
This commit is contained in:
@@ -160,7 +160,15 @@ void Hub::onTriggerState(const std::string& json) {
|
||||
trigger_.trigTime = msg.trigTime;
|
||||
trigger_.hasTrigTime = true;
|
||||
}
|
||||
if (msg.state == "idle") { trigger_.hasTrigTime = false; }
|
||||
if (msg.hasWindow) {
|
||||
trigger_.firedPreS = msg.preSec;
|
||||
trigger_.firedPostS = msg.postSec;
|
||||
trigger_.hasFiredWin = true;
|
||||
}
|
||||
if (msg.state == "idle") {
|
||||
trigger_.hasTrigTime = false;
|
||||
trigger_.hasFiredWin = false;
|
||||
}
|
||||
Q_EMIT triggerStateChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ struct TriggerCfgState {
|
||||
bool stopped = false;
|
||||
bool hasTrigTime = false;
|
||||
double trigTime = 0.0;
|
||||
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
|
||||
* above, which are editable and may have moved on since the trigger fired. */
|
||||
bool hasFiredWin = false;
|
||||
double firedPreS = 0.0;
|
||||
double firedPostS = 0.0;
|
||||
};
|
||||
|
||||
/** Per-signal vertical scale state (oscilloscope style). */
|
||||
|
||||
@@ -78,6 +78,49 @@ 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; } }
|
||||
@@ -189,6 +232,50 @@ void PlotCanvas::drawMarker(QPainter& p, double cx, double cy, int marker, doubl
|
||||
}
|
||||
}
|
||||
|
||||
/** @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);
|
||||
@@ -205,13 +292,14 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
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_;
|
||||
|
||||
const TrigView tv = resolveTrigView(hub, gv, paused);
|
||||
const CaptureFrame* cap = hub->capture();
|
||||
|
||||
/* ── pause snapshot ─────────────────────────────────────────────────── */
|
||||
auto& snap = w_->snap_;
|
||||
if (paused) {
|
||||
@@ -239,15 +327,15 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
/* ── gather data per slot ───────────────────────────────────────────── */
|
||||
std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size());
|
||||
|
||||
const bool liveHiRes = !trigView && live && !paused &&
|
||||
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 = !trigView && !paused && zc.valid &&
|
||||
const bool useZoomData = !tv.rel && !paused && zc.valid &&
|
||||
(liveHiRes ||
|
||||
(!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9));
|
||||
|
||||
bool useHistData = !trigView && !paused && !live && hc.valid &&
|
||||
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;
|
||||
@@ -271,17 +359,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
|
||||
const std::string key = hub->slotKey(a);
|
||||
|
||||
if (trigView) {
|
||||
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] - cap->trigTime);
|
||||
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) {
|
||||
@@ -302,11 +398,18 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
resolveVScale(a, sig, vStore[si]);
|
||||
}
|
||||
|
||||
if (w_->vMode_ == 3) {
|
||||
resolveUnifiedVScale(w_->uniVS_, slots, sources, vStore);
|
||||
}
|
||||
|
||||
/* ── X range ────────────────────────────────────────────────────────── */
|
||||
double xMin, xMax;
|
||||
if (trigView) {
|
||||
if (tv.rel) {
|
||||
if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; }
|
||||
else { xMin = -cap->preSec; xMax = cap->postSec; }
|
||||
/* 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; }
|
||||
@@ -319,19 +422,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
/* ── 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();
|
||||
/* 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 (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
|
||||
w_->activeSlot_ < (int)slots.size()) {
|
||||
double rawVal = av.resolvedOffset + (d - av.screenPos) * av.resolvedDiv;
|
||||
lbl = fmtVal(rawVal);
|
||||
if (axisVS != nullptr) {
|
||||
lbl = fmtVal(axisVS->resolvedOffset +
|
||||
(d - axisVS->screenPos) * axisVS->resolvedDiv);
|
||||
} else {
|
||||
lbl = QString::number(d);
|
||||
}
|
||||
@@ -346,7 +455,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
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);
|
||||
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);
|
||||
@@ -396,8 +505,10 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
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], a.vs);
|
||||
for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], nvs);
|
||||
}
|
||||
|
||||
QColor c = sig.color;
|
||||
@@ -423,7 +534,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
|
||||
}
|
||||
|
||||
/* trigger instant marker at t=0 */
|
||||
if (trigView) {
|
||||
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()));
|
||||
@@ -476,8 +587,7 @@ 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;
|
||||
const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
|
||||
bool& live = w_->live_;
|
||||
|
||||
double dy = e->angleDelta().y();
|
||||
@@ -488,43 +598,51 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) {
|
||||
const double now = nowSec();
|
||||
|
||||
auto enterTrigZoom = [&]() {
|
||||
if (trigView && !w_->trigZoomed_) {
|
||||
w_->setStoredX(-cap->preSec, cap->postSec);
|
||||
if (tv.rel && !w_->trigZoomed_) {
|
||||
w_->setStoredX(-tv.preS, tv.postS);
|
||||
w_->trigZoomed_ = true;
|
||||
}
|
||||
};
|
||||
auto xZoomStored = [&](double f) {
|
||||
if (trigView) enterTrigZoom();
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
/* 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 (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
|
||||
if (!tv.rel && 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;
|
||||
if (wheelVS != nullptr) {
|
||||
makeManual(*wheelVS);
|
||||
wheelVS->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);
|
||||
if (wheelVS != nullptr) {
|
||||
makeManual(*wheelVS);
|
||||
wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
|
||||
} else {
|
||||
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
|
||||
if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
|
||||
else xZoomStored(factor);
|
||||
}
|
||||
}
|
||||
@@ -549,8 +667,7 @@ 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;
|
||||
const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
|
||||
bool& live = w_->live_;
|
||||
|
||||
if (dragCursor_ != 0) {
|
||||
@@ -560,11 +677,11 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
|
||||
return;
|
||||
}
|
||||
if (panning_) {
|
||||
if (trigView && !w_->trigZoomed_) {
|
||||
w_->setStoredX(-cap->preSec, cap->postSec);
|
||||
if (tv.rel && !w_->trigZoomed_) {
|
||||
w_->setStoredX(-tv.preS, tv.postS);
|
||||
w_->trigZoomed_ = true;
|
||||
}
|
||||
if (!trigView && live) { w_->initPlotX(nowSec()); live = false; }
|
||||
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_;
|
||||
@@ -677,11 +794,10 @@ void PlotWidget::onCaptureReceived() {
|
||||
void PlotWidget::tick() {
|
||||
Hub* hub = hub_;
|
||||
GlobalView* gv = gv_;
|
||||
const CaptureFrame* cap = hub->capture();
|
||||
const bool trigView = (cap != nullptr) && gv->trigView;
|
||||
const TrigView tv = resolveTrigView(hub, gv, paused_);
|
||||
const double now = nowSec();
|
||||
|
||||
if (!trigView && !paused_) {
|
||||
if (!tv.rel && !paused_) {
|
||||
std::string csv;
|
||||
for (const auto& a : slots_) {
|
||||
std::string k = hub->slotKey(a);
|
||||
@@ -736,9 +852,13 @@ void PlotWidget::rebuildHeader() {
|
||||
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)));
|
||||
/* 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;
|
||||
@@ -797,11 +917,17 @@ void PlotWidget::rebuildHeader() {
|
||||
headerLay_->addWidget(fit);
|
||||
}
|
||||
|
||||
/* N / D / M */
|
||||
const char* vl[3] = {"N", "D", "M"};
|
||||
for (int vm = 0; vm < 3; vm++) {
|
||||
/* 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[vm]);
|
||||
vb->setText(vl[i]);
|
||||
vb->setToolTip(vtip[i]);
|
||||
vb->setCheckable(true);
|
||||
vb->setChecked(vMode_ == vm);
|
||||
connect(vb, &QToolButton::clicked, this, [this, vm]() {
|
||||
@@ -810,9 +936,57 @@ void PlotWidget::rebuildHeader() {
|
||||
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;
|
||||
@@ -846,30 +1020,12 @@ void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
|
||||
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(); });
|
||||
/* 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);
|
||||
}
|
||||
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", [&]() {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
class QHBoxLayout;
|
||||
class QToolButton;
|
||||
class QLabel;
|
||||
class QMenu;
|
||||
|
||||
namespace shq {
|
||||
|
||||
@@ -69,6 +70,8 @@ private:
|
||||
friend class PlotCanvas;
|
||||
|
||||
void rebuildHeader();
|
||||
void buildVScaleMenu(QMenu* vs, VScale& evs);
|
||||
void showUnifiedVScaleMenu(const QPoint& globalPos);
|
||||
void showBadgeMenu(int slotIdx, const QPoint& globalPos);
|
||||
void pushZoomHist();
|
||||
void initPlotX(double tMax);
|
||||
@@ -87,7 +90,8 @@ private:
|
||||
bool paused_ = false;
|
||||
double plotXMin_ = 0.0;
|
||||
double plotXMax_ = 0.0;
|
||||
int vMode_ = 0; /* 0 normal 1 digital 2 mixed */
|
||||
int vMode_ = 0; /* 0 normal 1 digital 2 mixed 3 unified */
|
||||
VScale uniVS_; /* the one scale every trace shares in mode 3 */
|
||||
int activeSlot_ = -1;
|
||||
bool trigZoomed_ = false;
|
||||
|
||||
|
||||
@@ -825,11 +825,17 @@ void App::onTriggerState(const std::string& json) {
|
||||
trigger_.trigTime = msg.trigTime;
|
||||
trigger_.hasTrigTime = true;
|
||||
}
|
||||
if (msg.hasWindow) {
|
||||
trigger_.firedPreS = msg.preSec;
|
||||
trigger_.firedPostS = msg.postSec;
|
||||
trigger_.hasFiredWin = true;
|
||||
}
|
||||
/* Double-buffer semantics: the last recorded capture stays on display
|
||||
* (even while re-armed/collecting) and is only replaced when a new
|
||||
* capture frame has been fully received and parsed (handleBinary v2). */
|
||||
if (msg.state == "idle") {
|
||||
trigger_.hasTrigTime = false;
|
||||
trigger_.hasFiredWin = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -61,6 +61,11 @@ struct TriggerState {
|
||||
bool stopped = false;
|
||||
bool hasTrigTime = false;
|
||||
double trigTime = 0.0;
|
||||
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
|
||||
* above, which are editable and may have moved on since the trigger fired. */
|
||||
bool hasFiredWin = false;
|
||||
double firedPreS = 0.0;
|
||||
double firedPostS = 0.0;
|
||||
};
|
||||
|
||||
/** Per-signal vertical scale state (oscilloscope style). */
|
||||
@@ -183,9 +188,12 @@ public:
|
||||
plotXMax_[i] = tMax;
|
||||
}
|
||||
|
||||
/** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */
|
||||
/** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed 3=unified. */
|
||||
int& plotVMode(int i) { return plotVMode_[i]; }
|
||||
|
||||
/** @brief The one scale every trace shares in unified mode (vMode 3). */
|
||||
VScale& plotUnifiedVS(int i) { return plotUniVS_[i]; }
|
||||
|
||||
/* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */
|
||||
bool& cursorsOn() { return cursorsOn_; }
|
||||
double& cursorA() { return cursorA_; }
|
||||
@@ -302,7 +310,8 @@ private:
|
||||
double windowSec_ = 10.0; /* live scroll window width */
|
||||
double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */
|
||||
double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */
|
||||
int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */
|
||||
int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed 3=unified */
|
||||
VScale plotUniVS_[kMaxPlotSlots]; /* shared scale used by vMode 3 */
|
||||
|
||||
/* Cursors (global) */
|
||||
bool cursorsOn_ = false;
|
||||
|
||||
+192
-61
@@ -85,6 +85,49 @@ 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. Signals whose slot is empty contribute nothing. */
|
||||
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) { /* manual */
|
||||
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
|
||||
vs.resolvedOffset = vs.offset;
|
||||
return;
|
||||
}
|
||||
double mn = 1e300, mx = -1e300;
|
||||
if (vs.mode == 1) { /* range: union of every declared range */
|
||||
for (const auto& a : slots) {
|
||||
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.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;
|
||||
}
|
||||
|
||||
/** Min/max of a vector (returns false if empty/non-finite). */
|
||||
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
|
||||
mn = 1e300; mx = -1e300;
|
||||
@@ -149,9 +192,36 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
const double wallNow = std::chrono::duration<double>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
||||
/* Trigger view: render the hub capture relative to the trigger instant */
|
||||
/* Trigger view: render the hub capture relative to the trigger instant.
|
||||
*
|
||||
* Two ways to end up in trigger-relative time. Either a v2 capture frame
|
||||
* has arrived (trigView), or a trigger has fired and its window is still
|
||||
* filling (trigFill). 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 this client's own rings on the
|
||||
* final axis, growing left to right. Filling wins over the previous
|
||||
* capture: once a new trigger fires, the stale waveform is history. */
|
||||
const CaptureFrame* cap = app.capture();
|
||||
const bool trigView = (cap != nullptr) && app.showTrigBar();
|
||||
const TriggerState& trg = app.trigger();
|
||||
/* 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. */
|
||||
const double fillPreS = trg.hasFiredWin ? trg.firedPreS
|
||||
: trg.windowSec * trg.prePercent * 0.01;
|
||||
const double fillPostS = trg.hasFiredWin ? trg.firedPostS
|
||||
: trg.windowSec - fillPreS;
|
||||
|
||||
const bool trigFill = app.showTrigBar() && !paused &&
|
||||
trg.status == "collecting" && trg.hasTrigTime;
|
||||
const bool trigView = (cap != nullptr) && app.showTrigBar() && !trigFill;
|
||||
const bool trigRel = trigView || trigFill;
|
||||
|
||||
/* Window edges of whatever is on screen. 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. */
|
||||
const double trigT = trigView ? cap->trigTime : trg.trigTime;
|
||||
const double trigPreS = trigView ? cap->preSec : fillPreS;
|
||||
const double trigPostS = trigView ? cap->postSec : fillPostS;
|
||||
|
||||
/* Hi-res zoom cache for this plot */
|
||||
auto& zc = app.zoomCache(plotIdx);
|
||||
@@ -194,13 +264,13 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
* from decimated pushes) undersamples the visible range. Periodically
|
||||
* fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X
|
||||
* axis to the fetched slice (scope-style refresh at the fetch rate). */
|
||||
const bool liveHiRes = !trigView && live && !paused &&
|
||||
const bool liveHiRes = !trigRel && live && !paused &&
|
||||
app.windowSec() <= kLiveHiResMaxWin &&
|
||||
zc.valid &&
|
||||
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
|
||||
(wallNow - zc.t1) < 3.0;
|
||||
|
||||
const bool useZoomData = !trigView && !paused && zc.valid &&
|
||||
const bool useZoomData = !trigRel && !paused && zc.valid &&
|
||||
(liveHiRes ||
|
||||
(!live &&
|
||||
zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
|
||||
@@ -215,7 +285,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
const bool haveHistCover = hc.valid &&
|
||||
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
|
||||
hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
|
||||
bool useHistData = !trigView && !paused && !live && haveHistCover;
|
||||
bool useHistData = !trigRel && !paused && !live && haveHistCover;
|
||||
if (useHistData) {
|
||||
/* Check that at least one signal has actual data points */
|
||||
bool anyData = false;
|
||||
@@ -231,7 +301,12 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
* copied tens of MB per signal per frame. A 10% margin keeps a sample on
|
||||
* each side so the later fine clip still has its boundary points. */
|
||||
double visT0, visT1;
|
||||
if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); }
|
||||
if (trigFill) {
|
||||
/* Absolute bounds of the trigger window: the ring is indexed on the
|
||||
* hub clock, the axis on trigger-relative time. */
|
||||
visT0 = trigT - trigPreS; visT1 = trigT + trigPostS;
|
||||
}
|
||||
else if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); }
|
||||
else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); }
|
||||
{
|
||||
double margin = (visT1 - visT0) * 0.1;
|
||||
@@ -272,6 +347,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (trigFill) {
|
||||
/* Live ring, clipped to the (absolute) window and shifted onto the
|
||||
* trigger-relative axis. visT0/visT1 already carry a margin, so
|
||||
* clip here rather than reusing readBase. */
|
||||
(void) sig.buf.readRange(trigT - trigPreS, trigT + trigPostS,
|
||||
tStore[si], vStore[si]);
|
||||
for (size_t i = 0; i < tStore[si].size(); i++) {
|
||||
tStore[si][i] -= trigT;
|
||||
}
|
||||
} else if (useZoomData) {
|
||||
bool found = false;
|
||||
for (const auto& zs : zc.signals) {
|
||||
@@ -302,6 +386,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
resolveVScale(a, sig, vStore[si]);
|
||||
}
|
||||
|
||||
VScale& uniVS = app.plotUnifiedVS(plotIdx);
|
||||
if (vMode == 3) {
|
||||
resolveUnifiedVScale(uniVS, slots, sources, vStore);
|
||||
}
|
||||
|
||||
/* clamp active slot */
|
||||
if (actSlot >= (int)slots.size()) actSlot = -1;
|
||||
|
||||
@@ -326,9 +415,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
ImVec4(0.067f,0.067f,0.106f,1.f));
|
||||
|
||||
char badge[80];
|
||||
/* show vscale info: resolved div value */
|
||||
/* Show the div value actually in force: the plot's shared one in
|
||||
* unified mode, this signal's otherwise. */
|
||||
char dvbuf[16];
|
||||
fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv);
|
||||
fmtVal(dvbuf, sizeof(dvbuf),
|
||||
(vMode == 3) ? uniVS.resolvedDiv : a.vs.resolvedDiv);
|
||||
snprintf(badge, sizeof(badge), "%s %s/div##b%d",
|
||||
sig.meta.name.c_str(), dvbuf, i);
|
||||
|
||||
@@ -398,7 +489,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
|
||||
/* Back / Fit / Reset (zoom history) */
|
||||
if (!live || (trigView && app.trigZoomed(plotIdx))) {
|
||||
if (!live || (trigRel && app.trigZoomed(plotIdx))) {
|
||||
ImGui::SameLine();
|
||||
auto& hist = app.zoomHist(plotIdx);
|
||||
if (hist.empty()) { ImGui::BeginDisabled(); }
|
||||
@@ -408,7 +499,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
if (hist.empty()) { ImGui::EndDisabled(); }
|
||||
ImGui::SameLine();
|
||||
if (trigView) {
|
||||
if (trigRel) {
|
||||
/* Reset to full capture window */
|
||||
if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
|
||||
app.trigZoomed(plotIdx) = false;
|
||||
@@ -440,10 +531,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
/* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */
|
||||
ImGui::SameLine();
|
||||
{
|
||||
static const char* kVLabels[] = {"N", "D", "M"};
|
||||
static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"};
|
||||
for (int vm = 0; vm < 3; vm++) {
|
||||
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm);
|
||||
static const char* kVLabels[] = {"N", "U", "D", "M"};
|
||||
static const char* kVTooltips[] = {
|
||||
"Normal: one vertical scale per signal",
|
||||
"Unified: one vertical scale shared by every signal",
|
||||
"Digital", "Mixed" };
|
||||
static const int kVModes[] = {0, 3, 1, 2};
|
||||
for (int i = 0; i < 4; i++) {
|
||||
const int vm = kVModes[i];
|
||||
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[i], plotIdx, vm);
|
||||
bool sel = (vMode == vm);
|
||||
if (sel) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
|
||||
@@ -451,28 +547,41 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
if (ImGui::SmallButton(vmId)) { vMode = vm; }
|
||||
if (sel) { ImGui::PopStyleColor(2); }
|
||||
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[vm]); }
|
||||
if (vm < 2) { ImGui::SameLine(0.f, 1.f); }
|
||||
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[i]); }
|
||||
if (i < 3) { ImGui::SameLine(0.f, 1.f); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── VScale toolbar (shown when an active signal is selected) ───────── */
|
||||
/* ── VScale toolbar ──────────────────────────────────────────────────── *
|
||||
* Normal mode edits the active signal's scale; unified mode edits the one
|
||||
* scale the whole plot shares, so it needs no selection. */
|
||||
VScale *toolVS = static_cast<VScale *>(0);
|
||||
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
auto& a = slots[actSlot];
|
||||
toolVS = &slots[actSlot].vs;
|
||||
} else if (vMode == 3) {
|
||||
toolVS = &uniVS;
|
||||
}
|
||||
if (toolVS != static_cast<VScale *>(0)) {
|
||||
VScale& tvs = *toolVS;
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f));
|
||||
|
||||
if (vMode == 3) {
|
||||
ImGui::TextDisabled("all signals");
|
||||
ImGui::SameLine(0.f,10.f);
|
||||
}
|
||||
|
||||
/* mode buttons */
|
||||
static const char* kModeLabels[] = {"Auto","Range","Manual"};
|
||||
for (int m = 0; m < 3; m++) {
|
||||
bool sel = (a.vs.mode == m);
|
||||
bool sel = (tvs.mode == m);
|
||||
if (sel) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button,
|
||||
ImVec4(0.537f,0.706f,0.980f,0.3f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
ImVec4(0.537f,0.706f,0.980f,1.f));
|
||||
}
|
||||
if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; }
|
||||
if (ImGui::SmallButton(kModeLabels[m])) { tvs.mode = m; }
|
||||
if (sel) ImGui::PopStyleColor(2);
|
||||
if (m < 2) ImGui::SameLine(0.f,2.f);
|
||||
}
|
||||
@@ -480,23 +589,23 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
|
||||
/* resolved info */
|
||||
char rbuf[24], obuf[24];
|
||||
fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv);
|
||||
fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset);
|
||||
fmtVal(rbuf, sizeof(rbuf), tvs.resolvedDiv);
|
||||
fmtVal(obuf, sizeof(obuf), tvs.resolvedOffset);
|
||||
|
||||
if (a.vs.mode == 2) { /* manual: editable */
|
||||
if (tvs.mode == 2) { /* manual: editable */
|
||||
ImGui::SetNextItemWidth(70.f);
|
||||
ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g");
|
||||
ImGui::InputDouble("V/div##vd", &tvs.divValue, 0,0,"%.4g");
|
||||
ImGui::SameLine(0.f,4.f);
|
||||
ImGui::SetNextItemWidth(80.f);
|
||||
ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g");
|
||||
ImGui::InputDouble("Offset##vo", &tvs.offset, 0,0,"%.4g");
|
||||
} else {
|
||||
ImGui::TextDisabled("%s/div @%s", rbuf, obuf);
|
||||
}
|
||||
ImGui::SameLine(0.f,10.f);
|
||||
ImGui::SetNextItemWidth(50.f);
|
||||
float sp = (float)a.vs.screenPos;
|
||||
float sp = (float)tvs.screenPos;
|
||||
if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
|
||||
a.vs.screenPos = sp;
|
||||
tvs.screenPos = sp;
|
||||
}
|
||||
|
||||
ImGui::PopStyleVar();
|
||||
@@ -539,7 +648,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) {
|
||||
|
||||
/* Both axes locked so ImPlot never overrides our explicit limits. */
|
||||
ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr,
|
||||
ImPlot::SetupAxes(trigRel ? "t - trig (s)" : "Time (s)", nullptr,
|
||||
ImPlotAxisFlags_Lock,
|
||||
ImPlotAxisFlags_Lock);
|
||||
|
||||
@@ -549,13 +658,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
/* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
|
||||
double xMin, xMax;
|
||||
bool& trigZm = app.trigZoomed(plotIdx);
|
||||
if (trigView) {
|
||||
if (trigRel) {
|
||||
if (trigZm) {
|
||||
xMin = app.plotXMin(plotIdx);
|
||||
xMax = app.plotXMax(plotIdx);
|
||||
} else {
|
||||
xMin = -cap->preSec;
|
||||
xMax = cap->postSec;
|
||||
/* Full window from the start, even while filling: a trace that
|
||||
* grows into a fixed axis reads as progress; an axis that
|
||||
* grows with the data makes the whole trace shift every
|
||||
* frame and the time base meaningless. */
|
||||
xMin = -trigPreS;
|
||||
xMax = trigPostS;
|
||||
}
|
||||
} else if (live && !paused) {
|
||||
if (liveHiRes) {
|
||||
@@ -568,7 +681,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
} else {
|
||||
xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx);
|
||||
}
|
||||
if (trigView || (live && !paused) || !live) {
|
||||
if (trigRel || (live && !paused) || !live) {
|
||||
if (xMax > xMin) {
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always);
|
||||
}
|
||||
@@ -579,8 +692,16 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
static char yTickBufs[9][20];
|
||||
static const char* yTickLabels[9];
|
||||
|
||||
const VScale *axisVS = static_cast<const VScale *>(0);
|
||||
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
const auto& av = slots[actSlot].vs;
|
||||
axisVS = &slots[actSlot].vs;
|
||||
} else if (vMode == 3) {
|
||||
/* Unified: the shared scale labels the axis for every trace at
|
||||
* once, so no signal has to be selected first. */
|
||||
axisVS = &uniVS;
|
||||
}
|
||||
if (axisVS != static_cast<const VScale *>(0)) {
|
||||
const VScale& av = *axisVS;
|
||||
for (int d = 0; d < 9; d++) {
|
||||
double divPos = yTickVals[d];
|
||||
double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv;
|
||||
@@ -636,15 +757,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
|
||||
/* Helper: enter zoomed mode for trigger view (seed from capture window) */
|
||||
auto enterTrigZoom = [&]() {
|
||||
if (trigView && !trigZm) {
|
||||
app.setPlotX(plotIdx, -cap->preSec, cap->postSec);
|
||||
if (trigRel && !trigZm) {
|
||||
app.setPlotX(plotIdx, -trigPreS, trigPostS);
|
||||
trigZm = true;
|
||||
}
|
||||
};
|
||||
|
||||
/* Helper: X-zoom the stored range by factor around center */
|
||||
auto xZoomStored = [&](double factor) {
|
||||
if (trigView) { enterTrigZoom(); }
|
||||
if (trigRel) { enterTrigZoom(); }
|
||||
if (now - lastHistPush[plotIdx] > 0.6) {
|
||||
app.pushZoomHist(plotIdx);
|
||||
lastHistPush[plotIdx] = now;
|
||||
@@ -659,37 +780,45 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
const double zoomOut = 1.25;
|
||||
double factor = (wheel > 0.f) ? zoomIn : zoomOut;
|
||||
|
||||
/* Scroll adjusts the scale the axis is labelled with: the
|
||||
* active signal's in normal mode, the plot's shared one in
|
||||
* unified mode (where there is nothing to select). */
|
||||
VScale *wheelVS = static_cast<VScale *>(0);
|
||||
if (vMode == 3) {
|
||||
wheelVS = &uniVS;
|
||||
} else if (actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
wheelVS = &slots[actSlot].vs;
|
||||
}
|
||||
/* Seed manual from the resolved values so the gesture sticks. */
|
||||
auto latchManual = [](VScale& v) {
|
||||
if (v.mode != 2) {
|
||||
v.divValue = std::max(v.resolvedDiv, 1e-30);
|
||||
v.offset = v.resolvedOffset;
|
||||
v.mode = 2;
|
||||
}
|
||||
};
|
||||
|
||||
if (ctrl) {
|
||||
/* ── X zoom ─────────────────────────────────────────── */
|
||||
if (!trigView && live) {
|
||||
if (!trigRel && live) {
|
||||
app.setWindowSec(app.windowSec() * factor);
|
||||
} else {
|
||||
xZoomStored(factor);
|
||||
}
|
||||
} else if (shift) {
|
||||
/* ── Y offset of active signal ───────────────────────── */
|
||||
if (actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
auto& a = slots[actSlot];
|
||||
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;
|
||||
}
|
||||
a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5;
|
||||
/* ── Y pan ───────────────────────────────────────────── */
|
||||
if (wheelVS != static_cast<VScale *>(0)) {
|
||||
latchManual(*wheelVS);
|
||||
wheelVS->screenPos += (wheel > 0.f) ? 0.5 : -0.5;
|
||||
}
|
||||
} else {
|
||||
/* ── Y zoom of active signal ─────────────────────────── */
|
||||
if (actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
auto& a = slots[actSlot];
|
||||
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;
|
||||
}
|
||||
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
|
||||
/* ── Y zoom ──────────────────────────────────────────── */
|
||||
if (wheelVS != static_cast<VScale *>(0)) {
|
||||
latchManual(*wheelVS);
|
||||
wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
|
||||
} else {
|
||||
/* No active signal: plain scroll → X zoom */
|
||||
if (!trigView && live) {
|
||||
if (!trigRel && live) {
|
||||
app.setWindowSec(app.windowSec() * factor);
|
||||
} else {
|
||||
xZoomStored(factor);
|
||||
@@ -701,8 +830,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
/* Right-drag → X pan. Transition live→non-live on drag start;
|
||||
* in trigger view, enter trigger-zoom mode. */
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
|
||||
if (trigView) { enterTrigZoom(); }
|
||||
if (!trigView && live) {
|
||||
if (trigRel) { enterTrigZoom(); }
|
||||
if (!trigRel && live) {
|
||||
app.initPlotX(plotIdx, wallNow);
|
||||
live = false;
|
||||
lastHistPush[plotIdx] = now;
|
||||
@@ -721,7 +850,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
|
||||
/* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */
|
||||
if (!trigView && !paused) {
|
||||
if (!trigRel && !paused) {
|
||||
std::string csv;
|
||||
for (const auto& a : slots) {
|
||||
std::string k = app.slotKey(a);
|
||||
@@ -821,9 +950,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
} else if (vMode == 2) { /* mixed */
|
||||
bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
|
||||
} else {
|
||||
/* unified shares one scale, normal gives each trace its own */
|
||||
const VScale& nvs = (vMode == 3) ? uniVS : a.vs;
|
||||
vNorm.resize(nOut);
|
||||
for (size_t k = 0; k < nOut; k++) {
|
||||
vNorm[k] = normalizeY(vDec[k], a.vs);
|
||||
vNorm[k] = normalizeY(vDec[k], nvs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +967,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
|
||||
}
|
||||
|
||||
/* Trigger instant marker (capture view: t = 0) */
|
||||
if (trigView) {
|
||||
if (trigRel) {
|
||||
double t0m = 0.0;
|
||||
ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f),
|
||||
1.5f, ImPlotDragToolFlags_NoInputs);
|
||||
|
||||
@@ -458,6 +458,12 @@ bool ParseTriggerState(const std::string& json, TriggerStateMsg& out) {
|
||||
double tt = 0.0;
|
||||
out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt);
|
||||
out.trigTime = tt;
|
||||
|
||||
double pre = 0.0, post = 0.0;
|
||||
out.hasWindow = jsonGetDouble(json.c_str(), "preSec", pre) &&
|
||||
jsonGetDouble(json.c_str(), "postSec", post);
|
||||
out.preSec = pre;
|
||||
out.postSec = post;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,11 @@ struct TriggerStateMsg {
|
||||
bool stopped = false;
|
||||
bool hasTrigTime = false;
|
||||
double trigTime = 0.0;
|
||||
/* Window latched at fire time, sent alongside trigTime. Older hubs omit
|
||||
* it, hence hasWindow — fall back to the local trigger config then. */
|
||||
bool hasWindow = false;
|
||||
double preSec = 0.0;
|
||||
double postSec = 0.0;
|
||||
};
|
||||
|
||||
/*---------------------------------------------------------------------------*/
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"marte2/common/wshub"
|
||||
)
|
||||
@@ -21,20 +24,56 @@ var staticFiles embed.FS
|
||||
// multiFlag allows a flag to be repeated: --source a --source b
|
||||
type multiFlag []string
|
||||
|
||||
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
|
||||
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
|
||||
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
|
||||
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
|
||||
|
||||
// defaultHistoryDir is where samples are archived unless -history-dir says
|
||||
// otherwise. History is on by default because it is what holds a trigger
|
||||
// capture at full resolution: the in-memory rings roll past a captured window
|
||||
// within seconds of it being taken, and a zoom after that has nothing but the
|
||||
// capture's own decimated copy to draw. Per-signal files are bounded by
|
||||
// -history-max-mpts, so the default costs a fixed amount of space.
|
||||
func defaultHistoryDir() string {
|
||||
return filepath.Join(os.TempDir(), "udpstreamer-history")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var sourceArgs multiFlag
|
||||
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`)
|
||||
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
|
||||
listenAddr := flag.String("addr", ":8080", "HTTP listen address")
|
||||
histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)")
|
||||
histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards")
|
||||
histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files")
|
||||
histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes")
|
||||
histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)")
|
||||
histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)")
|
||||
ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)")
|
||||
flag.Parse()
|
||||
|
||||
hub := wshub.NewHub()
|
||||
// The budget bounds memory, not the window: a window too long to hold at the
|
||||
// source rate is buffered as min/max pairs rather than truncated to the tail.
|
||||
hub.SetRingBudget(int(*ringMPts * 1e6))
|
||||
sm := wshub.NewSourceManager(hub, *sourcesFile)
|
||||
hub.SetSourceManager(sm)
|
||||
|
||||
if err := hub.EnableHistory(wshub.HistoryConfig{
|
||||
Directory: *histDir,
|
||||
WindowSec: *histWindow,
|
||||
Decimation: *histDecim,
|
||||
FlushIntervalSec: *histFlush,
|
||||
MinDiskFreeMB: *histMinFree,
|
||||
MaxPointsPerSignal: int(*histMaxMPts * 1e6),
|
||||
}); err != nil {
|
||||
log.Fatalf("history: %v", err)
|
||||
}
|
||||
if *histDir == "" {
|
||||
log.Print("history disabled: zooming into a trigger capture will fall back " +
|
||||
"to the capture's own decimated copy once the rings roll past it")
|
||||
} else {
|
||||
log.Printf("history: %s", *histDir)
|
||||
}
|
||||
go hub.Run()
|
||||
|
||||
// Load sources from file first (if specified), then add any CLI --source flags.
|
||||
@@ -60,7 +99,21 @@ func main() {
|
||||
})
|
||||
|
||||
log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion)
|
||||
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
|
||||
|
||||
// Serve in the background so Ctrl-C can flush the history files: the
|
||||
// samples written since the last periodic flush are on disk but are not
|
||||
// yet accounted for in the file headers, so exiting outright loses them.
|
||||
srvErr := make(chan error, 1)
|
||||
go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-srvErr:
|
||||
hub.CloseHistory()
|
||||
log.Fatalf("http: %v", err)
|
||||
case s := <-sig:
|
||||
log.Printf("received %s, flushing history", s)
|
||||
hub.CloseHistory()
|
||||
}
|
||||
}
|
||||
|
||||
+794
-262
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
// Min/max (peak-envelope) decimation — O(n). Runs off-main-thread to avoid
|
||||
// blocking the render loop.
|
||||
//
|
||||
// The range is split into threshold/2 equal buckets and each contributes its
|
||||
// smallest and largest sample, in the order the two occurred — the way an
|
||||
// oscilloscope draws a trace it cannot show pixel-for-pixel.
|
||||
//
|
||||
// This replaced LTTB, which picks the sample forming the largest triangle with
|
||||
// its neighbours: a plausible-looking shape, but it silently drops a one-sample
|
||||
// spike whenever a smoother neighbour scores higher — exactly the sample worth
|
||||
// looking at. The envelope cannot drop it, because a spike is by definition its
|
||||
// bucket's min or max. Every output point is a real sample at its real
|
||||
// timestamp; nothing is interpolated or averaged.
|
||||
//
|
||||
// Kept identical to minMaxDecimate() in Common/Client/go/wshub/hub.go and to
|
||||
// decimate() in app.js, so a trace looks the same whichever thinned it.
|
||||
function decimate(t, v, threshold) {
|
||||
const len = t.length;
|
||||
if (len <= threshold || threshold < 4) {
|
||||
// Copy to new arrays so we can transfer them back without detaching the input.
|
||||
return { t: new Float64Array(t), v: new Float64Array(v) };
|
||||
}
|
||||
const buckets = threshold >> 1;
|
||||
const outT = new Float64Array(threshold);
|
||||
const outV = new Float64Array(threshold);
|
||||
let n = 0;
|
||||
for (let b = 0; b < buckets; b++) {
|
||||
const lo = Math.floor(b * len / buckets);
|
||||
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
|
||||
if (lo >= hi) continue;
|
||||
let iMin = lo, iMax = lo;
|
||||
for (let j = lo + 1; j < hi; j++) {
|
||||
if (v[j] < v[iMin]) iMin = j;
|
||||
if (v[j] > v[iMax]) iMax = j;
|
||||
}
|
||||
// Emit in time order so the result plots as one ascending trace.
|
||||
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
|
||||
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
|
||||
// A bucket whose samples are all equal has one extreme, not two.
|
||||
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
|
||||
}
|
||||
// slice() so the transferred buffers are exactly the used length.
|
||||
return { t: outT.slice(0, n), v: outV.slice(0, n) };
|
||||
}
|
||||
|
||||
self.onmessage = function({ data: { id, t, v, threshold } }) {
|
||||
const result = decimate(t, v, threshold);
|
||||
// Transfer the output buffers back to the main thread zero-copy.
|
||||
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
|
||||
};
|
||||
@@ -30,11 +30,14 @@
|
||||
</div>
|
||||
<span class="ctrl-label" id="lbl-window">Window:</span>
|
||||
<select id="window-select" class="ctrl-select">
|
||||
<option value="1">1 s</option><option value="5" selected>5 s</option>
|
||||
<option value="10">10 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
<option value="1">1 s</option><option value="2">2 s</option>
|
||||
<option value="5" selected>5 s</option><option value="10">10 s</option>
|
||||
<option value="15">15 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option><option value="120">2 min</option>
|
||||
<option value="300">5 min</option><option value="600">10 min</option>
|
||||
</select>
|
||||
<button id="btn-cursor" class="ctrl-btn">Cursors</button>
|
||||
<button id="btn-cursor-reset" class="ctrl-btn" style="display:none" title="Bring cursors A/B back into the visible window">↔ Reset</button>
|
||||
<button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</button>
|
||||
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
||||
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
||||
@@ -43,7 +46,8 @@
|
||||
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
||||
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||
<label class="ctrl-check" title="Snap jittery inter-frame timestamps to ideal spacing (eliminates overlaps/gaps from software-dispatch jitter)">
|
||||
<input type="checkbox" id="cb-monotonic"> Sync TS
|
||||
<input type="checkbox" id="cb-monotonic">
|
||||
Sync TS
|
||||
</label>
|
||||
</div>
|
||||
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
||||
@@ -70,10 +74,19 @@
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Window</span>
|
||||
<select id="trig-window" class="trig-select">
|
||||
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
|
||||
<option value="0.0001">100 μs</option><option value="0.0002">200 μs</option>
|
||||
<option value="0.0005">500 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.002">2 ms</option><option value="0.005">5 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.02">20 ms</option>
|
||||
<option value="0.05">50 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.2">200 ms</option><option value="0.5">500 ms</option>
|
||||
<option value="1" selected>1 s</option><option value="2">2 s</option>
|
||||
<option value="5">5 s</option><option value="10">10 s</option>
|
||||
<option value="20">20 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
<option value="120">2 m</option>
|
||||
<option value="300">5 m</option>
|
||||
<option value="600">10 m</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
@@ -83,6 +96,11 @@
|
||||
<span class="trig-range-val" id="trig-pre-val">20%</span>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label" title="Re-arm delay after a capture — prevents double triggering">Holdoff</span>
|
||||
<input id="trig-holdoff" class="trig-input" type="number" min="0" max="60" step="0.01" value="0.2">
|
||||
<span class="trig-label">s</span>
|
||||
</div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Mode</span>
|
||||
<select id="trig-mode" class="trig-select">
|
||||
@@ -124,10 +142,30 @@
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="sb-tsage"></span>
|
||||
<button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button>
|
||||
<span id="history-badge" style="display:none;font-size:10px;color:#f9e2af;margin-left:8px"></span>
|
||||
<button id="history-badge" style="display:none" title="Disk history — click to set the per-signal budget"></button>
|
||||
</div>
|
||||
<span id="build-version"></span>
|
||||
</div>
|
||||
<!-- ── History budget popup ──────────────────────────────────── -->
|
||||
<div id="history-panel" style="display:none">
|
||||
<div class="ctx-menu-header">Disk history budget</div>
|
||||
<div class="ctx-row">
|
||||
<label>Budget</label>
|
||||
<input type="number" id="hist-budget" class="ctx-num" min="0.001" step="1">
|
||||
<span class="ctx-range-val">MPts/signal</span>
|
||||
</div>
|
||||
<div class="hist-note">
|
||||
The budget buys resolution, not duration: a signal too fast to store
|
||||
sample-for-sample is archived as a min/max envelope wide enough to fit,
|
||||
so the configured window is always covered.
|
||||
</div>
|
||||
<div id="hist-signal-res"></div>
|
||||
<div class="hist-note hist-warn">Applying re-creates the history files — archived data is lost.</div>
|
||||
<div class="ctx-row" style="margin:0;justify-content:flex-end">
|
||||
<button class="ctx-btn" id="btn-hist-cancel">Cancel</button>
|
||||
<button class="ctx-btn" id="btn-hist-apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="layout-menu"></div>
|
||||
<!-- ── Signal style context menu ─────────────────────────────── -->
|
||||
<div id="sig-ctx-menu" style="display:none">
|
||||
@@ -172,7 +210,8 @@
|
||||
</div>
|
||||
<!-- ── Array index picker (trigger signal) ──────────────────────── -->
|
||||
<div id="array-idx-picker" style="display:none">
|
||||
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div>
|
||||
<div class="ctx-menu-header">Element index:
|
||||
<span id="aip-sig" class="ctx-menu-key"></span></div>
|
||||
<div class="ctx-row">
|
||||
<label>Index</label>
|
||||
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
|
||||
@@ -186,7 +225,8 @@
|
||||
<!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
|
||||
<div id="vscale-menu" style="display:none">
|
||||
<div class="vstb-header">
|
||||
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span>
|
||||
<span class="vstb-label"><span id="vscale-menu-title">V-Scale</span>:
|
||||
<span id="vscale-menu-key" class="ctx-menu-key"></span></span>
|
||||
<div class="ctx-btns" id="vscale-mode-btns">
|
||||
<button class="ctx-btn active" data-mode="auto">Auto</button>
|
||||
<button class="ctx-btn" data-mode="range">Range</button>
|
||||
@@ -200,10 +240,6 @@
|
||||
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label>
|
||||
<input type="number" id="vscale-offset" class="ctx-num" step="any" value="0">
|
||||
</div>
|
||||
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl">Pos</label>
|
||||
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
|
||||
</div>
|
||||
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl">Type</label>
|
||||
<div class="ctx-btns" id="vscale-type-btns">
|
||||
@@ -213,8 +249,7 @@
|
||||
</div>
|
||||
<div class="vstb-sep"></div>
|
||||
<div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl" id="vscale-cal-lbl"
|
||||
title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
|
||||
<label class="vstb-lbl" id="vscale-cal-lbl" title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
|
||||
<label class="vstb-lbl">Scale</label>
|
||||
<input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1">
|
||||
<label class="vstb-lbl">Offset</label>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
'use strict';
|
||||
// LTTB (Largest Triangle Three Buckets) decimation — O(n).
|
||||
// Runs off-main-thread to avoid blocking the render loop.
|
||||
function lttb(t, v, threshold) {
|
||||
const len = t.length;
|
||||
if (len <= threshold) {
|
||||
// Copy to new arrays so we can transfer them back without detaching the input.
|
||||
return { t: new Float64Array(t), v: new Float64Array(v) };
|
||||
}
|
||||
const outT = new Float64Array(threshold);
|
||||
const outV = new Float64Array(threshold);
|
||||
outT[0] = t[0]; outV[0] = v[0];
|
||||
outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
|
||||
const every = (len - 2) / (threshold - 2);
|
||||
let a = 0;
|
||||
for (let i = 0; i < threshold - 2; i++) {
|
||||
const avgS = Math.floor((i + 1) * every) + 1;
|
||||
const avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
|
||||
let avgT = 0, avgV = 0, n = 0;
|
||||
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
|
||||
if (n) { avgT /= n; avgV /= n; }
|
||||
const rS = Math.floor(i * every) + 1;
|
||||
const rE = Math.min(Math.floor((i + 1) * every) + 1, len);
|
||||
let maxA = -1, next = rS;
|
||||
const aT = t[a], aV = v[a];
|
||||
for (let j = rS; j < rE; j++) {
|
||||
const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
|
||||
if (area > maxA) { maxA = area; next = j; }
|
||||
}
|
||||
outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
|
||||
}
|
||||
return { t: outT, v: outV };
|
||||
}
|
||||
|
||||
self.onmessage = function({ data: { id, t, v, threshold } }) {
|
||||
const result = lttb(t, v, threshold);
|
||||
// Transfer the output buffers back to the main thread zero-copy.
|
||||
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
|
||||
};
|
||||
@@ -141,10 +141,17 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
|
||||
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
|
||||
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
|
||||
#btn-trig-rearm, #btn-trig-stop {
|
||||
#btn-trig-force, #btn-trig-rearm, #btn-trig-stop {
|
||||
border:none; border-radius:5px;
|
||||
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none;
|
||||
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer;
|
||||
}
|
||||
#btn-trig-rearm, #btn-trig-stop { display:none; }
|
||||
#btn-trig-force {
|
||||
background:var(--surface0); color:var(--text);
|
||||
border:1px solid var(--surface1);
|
||||
transition:background var(--transition),border-color var(--transition),color var(--transition);
|
||||
}
|
||||
#btn-trig-force:hover { background:var(--surface1); border-color:var(--mauve); color:var(--mauve); }
|
||||
#btn-trig-rearm { background:var(--mauve); color:var(--crust); }
|
||||
#btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); }
|
||||
#btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; }
|
||||
@@ -192,23 +199,6 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
|
||||
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
|
||||
.array-group {}
|
||||
.array-header {
|
||||
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
|
||||
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
|
||||
}
|
||||
.array-header:hover { background:var(--surface0); }
|
||||
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
|
||||
.array-header.open .array-arrow { transform:rotate(90deg); }
|
||||
.array-children { display:none; padding-left:16px; }
|
||||
.array-header.open + .array-children { display:block; }
|
||||
.array-child {
|
||||
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
|
||||
transition:background var(--transition); display:flex; align-items:center; gap:8px;
|
||||
user-select:none; color:var(--subtext1); font-size:12px;
|
||||
}
|
||||
.array-child:hover { background:var(--surface0); }
|
||||
.array-child:active { cursor:grabbing; }
|
||||
|
||||
/* ── Main area ────────────────────────────────────────────────── */
|
||||
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
|
||||
@@ -324,6 +314,32 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
border:1px solid var(--mauve);
|
||||
}
|
||||
|
||||
/* ── History budget ───────────────────────────────────────────── */
|
||||
#history-badge {
|
||||
font-size:10px; color:var(--yellow); margin-left:8px; cursor:pointer;
|
||||
background:transparent; border:1px solid transparent; border-radius:4px;
|
||||
padding:1px 5px; white-space:nowrap;
|
||||
}
|
||||
#history-badge:hover { border-color:var(--yellow); background:rgba(249,226,175,0.10); }
|
||||
#history-panel {
|
||||
position:fixed; z-index:300;
|
||||
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; width:290px;
|
||||
}
|
||||
.hist-note { font-size:10px; color:var(--overlay0); line-height:1.4; margin:6px 0; }
|
||||
.hist-warn { color:var(--peach); }
|
||||
#hist-signal-res {
|
||||
font-size:10px; font-family:monospace; color:var(--subtext0);
|
||||
max-height:120px; overflow-y:auto;
|
||||
border-top:1px solid var(--surface0); border-bottom:1px solid var(--surface0);
|
||||
padding:5px 0;
|
||||
}
|
||||
.hist-res-row { display:flex; justify-content:space-between; gap:8px; }
|
||||
.hist-res-row .hist-res-key {
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--subtext1);
|
||||
}
|
||||
.hist-res-row .hist-res-val { color:var(--mauve); flex-shrink:0; }
|
||||
|
||||
/* ── Signal style context menu ────────────────────────────────── */
|
||||
#sig-ctx-menu {
|
||||
position:fixed; z-index:300;
|
||||
|
||||
Reference in New Issue
Block a user