Files

1024 lines
44 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file PlotPanel.cpp
* @brief ImPlot oscilloscope panel — oscilloscope-style fixed Y axis.
*
* Y axis: always [-4, +4] divisions. Normalisation per plot mode:
* normal — each signal scaled by its VScale (auto / range / manual)
* digital — each signal quantised hi/lo within its own horizontal band
* mixed — per-signal band; quantised or auto-scaled (vs.digitalInMixed)
*
* X axis: live mode follows the wall clock (hub time base is Unix seconds);
* scroll/drag switches to a stored range with zoom history (Back/Fit/Live).
* When zoomed, a hi-res window is fetched from the hub over WS (zoom cmd).
* When a trigger capture exists (and the trigger bar is open) the panel
* renders the capture relative to the trigger instant in [-pre, +post].
*/
#include "PlotPanel.h"
#include "App.h"
#include "Protocol.h"
#include "SignalBuffer.h"
#include "imgui.h"
#include "implot.h"
#include "Icons.h"
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <chrono>
#include <vector>
#include <string>
namespace StreamHubClient {
/* DnD payload */
struct DragPayload { int srcIdx; int sigIdx; };
static const int kPlotSlotsMax = 8; /* mirrors App::kMaxPlotSlots */
/* Live windows at or below this width refresh via hub hi-res zoom fetches
* instead of (in addition to) the decimated push stream. Wide windows
* benefit too: LTTB on the server ring gives much better coverage than
* the client's accumulated push-stream data. */
static const double kLiveHiResMaxWin = 600.0; /* seconds (effectively always) */
/* ── Helpers ─────────────────────────────────────────────────────────────── */
static const char* fmtVal(char* buf, size_t sz, double v) {
if (!std::isfinite(v)) { snprintf(buf, sz, "?"); return buf; }
double abs = std::fabs(v);
if (v == 0.0) snprintf(buf, sz, "0");
else if (abs >= 1e4 || abs < 1e-3) snprintf(buf, sz, "%.2e", v);
else snprintf(buf, sz, "%.3g", v);
return buf;
}
/** Resolve the effective divValue and offset for this assignment given raw data. */
static void resolveVScale(PlotAssignment& a, const Signal& sig,
const std::vector<double>& rawV) {
if (a.vs.mode == 1) { /* range */
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;
}
/* fall through to auto if no valid range */
}
if (a.vs.mode == 2) { /* manual */
a.vs.resolvedDiv = std::max(a.vs.divValue, 1e-30);
a.vs.resolvedOffset = a.vs.offset;
return;
}
/* auto: fit data in central 6 of 8 divisions */
double mn = 1e300, mx = -1e300;
for (double v : rawV) { if (v < mn) mn = v; if (v > mx) mx = v; }
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
a.vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
a.vs.resolvedOffset = (mx + mn) / 2.0;
}
static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
}
/** Resolve the one scale every trace shares in unified mode.
*
* Same rules as the per-signal version, applied to the union of the plot:
* range takes the union of the declared ranges, auto fits the union of the
* data. 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;
for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } }
return mx >= mn;
}
/** Digital/mixed band normalisation (ports web applyDigitalNorm/applyMixedNorm).
* Band index ki of n; quantize=true → hi/lo threshold mode. */
static void bandNormalize(const std::vector<double>& raw, std::vector<double>& out,
int ki, int n, bool quantize) {
double bandH = 8.0 / std::max(n, 1);
double centerY = 4.0 - (ki + 0.5) * bandH;
double hi = centerY + bandH * 0.35;
double lo = centerY - bandH * 0.35;
double mn, mx;
bool haveMM = dataMinMax(raw, mn, mx);
out.resize(raw.size());
if (quantize) {
double thr = haveMM ? (mn + mx) / 2.0 : 0.5;
for (size_t i = 0; i < raw.size(); i++) {
double v = raw[i];
out[i] = !std::isfinite(v) ? NAN : (v >= thr ? hi : lo);
}
} else {
if (!haveMM) { mn = 0.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
double range = mx - mn, bandRange = hi - lo;
for (size_t i = 0; i < raw.size(); i++) {
double v = raw[i];
out[i] = !std::isfinite(v) ? NAN : lo + (v - mn) / range * bandRange;
}
}
}
/** Nearest sample value at time x (t sorted ascending). Returns NAN if empty. */
static double sampleAt(const std::vector<double>& t, const std::vector<double>& v,
double x) {
if (t.empty() || v.size() != t.size()) { return NAN; }
auto it = std::lower_bound(t.begin(), t.end(), x);
size_t i = static_cast<size_t>(it - t.begin());
if (i >= t.size()) { i = t.size() - 1; }
else if (i > 0 && (x - t[i-1]) < (t[i] - x)) { i--; }
return v[i];
}
/* ── Main render ─────────────────────────────────────────────────────────── */
void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
auto& slots = app.plotSlot(plotIdx);
auto& sources = app.sources();
bool& live = app.liveFollow(plotIdx);
int& actSlot = app.activeSlot(plotIdx);
int& vMode = app.plotVMode(plotIdx);
ImGui::PushID(plotIdx);
/* Hub timestamps are Unix wall-clock seconds: live window follows the
* local wall clock, not the newest data timestamp. */
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.
*
* 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 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);
/* ── Paused view snapshot (double buffer: active ring / frozen view) ── *
* The rings keep filling while paused; on the pause edge we freeze a
* copy of each slot's data and render from it until resumed. */
auto& snap = app.plotSnap(plotIdx);
if (paused) {
if (!snap.valid) {
snap.keys.clear(); snap.t.clear(); snap.v.clear();
for (const auto& a : slots) {
std::vector<double> tt, vv;
if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) {
(void) sources[a.sourceIdx].signals[a.signalIdx]
.buf.readLast((size_t)app.maxPoints(), tt, vv);
}
snap.keys.push_back(app.slotKey(a));
snap.t.push_back(std::move(tt));
snap.v.push_back(std::move(vv));
}
snap.valid = true;
/* Freeze the X axis where it is and switch to zoom/pan mode */
if (live) {
app.initPlotX(plotIdx, wallNow);
live = false;
}
}
} else if (snap.valid) {
snap.valid = false;
}
/* ── Collect raw data & resolve vscale ──────────────────────────────── */
/* per-slot storage so normalisation pass can reference them */
static thread_local std::vector<std::vector<double>> tStore, vStore;
tStore.resize(slots.size());
vStore.resize(slots.size());
/* Live hi-res refresh: with a narrow live window the client ring (built
* 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 = !trigRel && live && !paused &&
app.windowSec() <= kLiveHiResMaxWin &&
zc.valid &&
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
(wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigRel && !paused && zc.valid &&
(liveHiRes ||
(!live &&
zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
zc.t1 >= app.plotXMax(plotIdx) - 1e-9));
/* History zoom cache (disk-backed, for ranges beyond in-memory ring).
* Zoom responses carry the *requested* t0/t1 (not actual data bounds),
* so a zoom cache can report coverage even when the ring had no data.
* Prefer history over zoom when the in-memory ring cannot span the full
* visible window — that is the typical history-browse case. */
auto& hc = app.histZoomCache(plotIdx);
const bool haveHistCover = hc.valid &&
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
bool useHistData = !trigRel && !paused && !live && haveHistCover;
if (useHistData) {
/* Check that at least one signal has actual data points */
bool anyData = false;
for (const auto& hs : hc.signals) {
if (!hs.t.empty()) { anyData = true; break; }
}
if (!anyData) { useHistData = false; }
}
/* Visible X window to read from the ring. Reading only the visible slice
* (binary-searched) instead of the entire ring — which can hold a million
* points — is what keeps live rendering fluid; the full-ring readLast()
* 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 (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;
if (!(margin > 0.0)) { margin = 1.0; }
visT0 -= margin; visT1 += margin;
}
/* Fill tStore/vStore[si] from the frozen view (paused) or the live ring */
auto readBase = [&](size_t si, const Signal& 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;
}
}
}
(void) sig.buf.readRange(visT0, visT1, tStore[si], vStore[si]);
};
for (size_t si = 0; si < slots.size(); si++) {
auto& a = slots[si];
tStore[si].clear(); vStore[si].clear();
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
const std::string key = app.slotKey(a);
if (trigView) {
/* Capture signals carry full keys "src:sig"; t relative to trigTime */
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 (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) {
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.signals) {
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]);
}
VScale& uniVS = app.plotUnifiedVS(plotIdx);
if (vMode == 3) {
resolveUnifiedVScale(uniVS, slots, sources, vStore);
}
/* clamp active slot */
if (actSlot >= (int)slots.size()) actSlot = -1;
/* ── Badge row ──────────────────────────────────────────────────────── */
/* Catppuccin accent for active badge border */
static const ImVec4 kAccent{0.537f,0.706f,0.980f,1.f};
for (int i = (int)slots.size()-1; i >= 0; i--) {
auto& a = slots[i];
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
bool isActive = (actSlot == i);
/* colored badge */
ImGui::PushStyleColor(ImGuiCol_Button,
isActive ? kAccent : sig.color);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
isActive ? kAccent : ImVec4(sig.color.x*1.15f,sig.color.y*1.15f,
sig.color.z*1.15f,sig.color.w));
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.067f,0.067f,0.106f,1.f));
char badge[80];
/* 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),
(vMode == 3) ? uniVS.resolvedDiv : a.vs.resolvedDiv);
snprintf(badge, sizeof(badge), "%s %s/div##b%d",
sig.meta.name.c_str(), dvbuf, i);
if (ImGui::SmallButton(badge)) {
actSlot = (actSlot == i) ? -1 : i; /* toggle active */
}
ImGui::PopStyleColor(3);
/* right-click: styling + remove */
char popId[24]; snprintf(popId, sizeof(popId), "##bp%d_%d", plotIdx, i);
if (ImGui::BeginPopupContextItem(popId)) {
ImGui::TextUnformatted(sig.meta.name.c_str());
ImGui::Separator();
float col[4] = {sig.color.x, sig.color.y, sig.color.z, sig.color.w};
if (ImGui::ColorEdit4("Color##sc", col,
ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) {
sig.color = ImVec4(col[0], col[1], col[2], col[3]);
}
ImGui::SetNextItemWidth(120.f);
ImGui::SliderFloat("Width##sw", &sig.lineWidth, 0.5f, 5.f, "%.1f");
static const char* kMarkerNames[] =
{"None", "Circle", "Square", "Diamond", "Up", "Down",
"Left", "Right", "Cross", "Plus", "Asterisk"};
int mk = sig.marker + 1; /* -1 (none) → 0 */
ImGui::SetNextItemWidth(120.f);
if (ImGui::Combo("Marker##sm", &mk, kMarkerNames, 11)) {
sig.marker = mk - 1;
}
if (vMode == 2) { /* mixed mode: per-trace digital toggle */
ImGui::Checkbox("Digital (mixed)##sd", &a.vs.digitalInMixed);
}
ImGui::Separator();
bool shouldBreak = false;
if (ImGui::MenuItem("Remove from plot")) {
if (actSlot == i) actSlot = -1;
else if (actSlot > i) actSlot--;
slots.erase(slots.begin() + i);
shouldBreak = true;
}
ImGui::EndPopup();
if (shouldBreak) { ImGui::SameLine(); break; }
}
ImGui::SameLine();
}
/* live button (pause & cursors live in the global toolbar) */
{
static const ImVec4 kLiveOn {0.086f,0.494f,0.267f,1.f};
static const ImVec4 kLiveOnH{0.114f,0.627f,0.341f,1.f};
static const ImVec4 kLiveOff{0.192f,0.196f,0.267f,1.f};
static const ImVec4 kLiveOffH{0.271f,0.278f,0.353f,1.f};
ImGui::PushStyleColor(ImGuiCol_Button,
live ? kLiveOn : kLiveOff);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
live ? kLiveOnH : kLiveOffH);
if (ImGui::SmallButton(ICON_FA_TOWER_BROADCAST " Live")) {
live = true;
paused = false;
app.zoomHist(plotIdx).clear();
zc.valid = false;
}
ImGui::PopStyleColor(2);
}
/* Back / Fit / Reset (zoom history) */
if (!live || (trigRel && app.trigZoomed(plotIdx))) {
ImGui::SameLine();
auto& hist = app.zoomHist(plotIdx);
if (hist.empty()) { ImGui::BeginDisabled(); }
if (ImGui::SmallButton(ICON_FA_ARROW_ROTATE_LEFT " Back##zb")) {
app.setPlotX(plotIdx, hist.back().first, hist.back().second);
hist.pop_back();
}
if (hist.empty()) { ImGui::EndDisabled(); }
ImGui::SameLine();
if (trigRel) {
/* Reset to full capture window */
if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
app.trigZoomed(plotIdx) = false;
app.zoomHist(plotIdx).clear();
}
} else {
if (ImGui::SmallButton(ICON_FA_EXPAND " Fit##zf")) {
double mn = 1e300, mx = -1e300;
for (size_t si = 0; si < slots.size(); si++) {
if (tStore[si].empty()) continue;
mn = std::min(mn, tStore[si].front());
mx = std::max(mx, tStore[si].back());
}
if (mx > mn) {
app.pushZoomHist(plotIdx);
app.setPlotX(plotIdx, mn, mx);
}
}
}
}
/* History indicator */
if (useHistData) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f),
ICON_FA_CLOCK_ROTATE_LEFT " HIST");
}
/* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */
ImGui::SameLine();
{
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));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.537f,0.706f,0.980f,1.f));
}
if (ImGui::SmallButton(vmId)) { vMode = vm; }
if (sel) { ImGui::PopStyleColor(2); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[i]); }
if (i < 3) { ImGui::SameLine(0.f, 1.f); }
}
}
/* ── 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()) {
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 = (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])) { tvs.mode = m; }
if (sel) ImGui::PopStyleColor(2);
if (m < 2) ImGui::SameLine(0.f,2.f);
}
ImGui::SameLine(0.f,10.f);
/* resolved info */
char rbuf[24], obuf[24];
fmtVal(rbuf, sizeof(rbuf), tvs.resolvedDiv);
fmtVal(obuf, sizeof(obuf), tvs.resolvedOffset);
if (tvs.mode == 2) { /* manual: editable */
ImGui::SetNextItemWidth(70.f);
ImGui::InputDouble("V/div##vd", &tvs.divValue, 0,0,"%.4g");
ImGui::SameLine(0.f,4.f);
ImGui::SetNextItemWidth(80.f);
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)tvs.screenPos;
if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
tvs.screenPos = sp;
}
ImGui::PopStyleVar();
}
/* ── Cursor readouts ─────────────────────────────────────────────────── */
if (app.cursorsOn()) {
double cA = app.cursorA(), cB = app.cursorB();
double dT = cB - cA;
char d1[24], d2[24];
fmtVal(d1, sizeof(d1), dT);
fmtVal(d2, sizeof(d2), (dT != 0.0) ? 1.0 / std::fabs(dT) : 0.0);
ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f),
"dT=%ss 1/dT=%sHz", d1, d2);
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], cA);
double vB = sampleAt(tStore[si], vStore[si], cB);
char a1[24], a2[24], a3[24];
fmtVal(a1, sizeof(a1), vA);
fmtVal(a2, sizeof(a2), vB);
fmtVal(a3, sizeof(a3), vB - vA);
ImGui::SameLine(0.f, 14.f);
ImGui::TextColored(sig.color, "%s: A=%s B=%s d=%s",
sig.meta.name.c_str(), a1, a2, a3);
}
}
ImGui::Separator();
/* ── ImPlot ──────────────────────────────────────────────────────────── */
char plotId[32]; snprintf(plotId, sizeof(plotId), "##plot%d", plotIdx);
/* Both axes are fully controlled by us — disable ImPlot's native zoom. */
ImPlotFlags plotFlags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend
| ImPlotFlags_NoInputs;
if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) {
/* Both axes locked so ImPlot never overrides our explicit limits. */
ImPlot::SetupAxes(trigRel ? "t - trig (s)" : "Time (s)", nullptr,
ImPlotAxisFlags_Lock,
ImPlotAxisFlags_Lock);
/* Y axis always ±4 div space */
ImPlot::SetupAxisLimits(ImAxis_Y1, -4.05, 4.05, ImGuiCond_Always);
/* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
double xMin, xMax;
bool& trigZm = app.trigZoomed(plotIdx);
if (trigRel) {
if (trigZm) {
xMin = app.plotXMin(plotIdx);
xMax = app.plotXMax(plotIdx);
} else {
/* 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) {
/* Anchor to the latest fetched hi-res slice so the trace
* fills the window (refreshes at the fetch rate). */
xMax = zc.t1; xMin = zc.t1 - app.windowSec();
} else {
xMax = wallNow; xMin = wallNow - app.windowSec();
}
} else {
xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx);
}
if (trigRel || (live && !paused) || !live) {
if (xMax > xMin) {
ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always);
}
}
/* ── Y axis ticks labelled from active signal (normal mode only) ── */
static double yTickVals[9] = {-4,-3,-2,-1,0,1,2,3,4};
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()) {
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;
fmtVal(yTickBufs[d], sizeof(yTickBufs[d]), rawVal);
yTickLabels[d] = yTickBufs[d];
}
} else {
for (int d = 0; d < 9; d++) {
snprintf(yTickBufs[d], sizeof(yTickBufs[d]), "%.0f", yTickVals[d]);
yTickLabels[d] = yTickBufs[d];
}
}
ImPlot::SetupAxisTicks(ImAxis_Y1, yTickVals, 9, yTickLabels, false);
/* ── X axis ticks ───────────────────────────────────────────────── */
if (xMax > xMin) {
double step = (xMax - xMin) / 10.0;
double tickX[11];
for (int t = 0; t <= 10; t++) tickX[t] = xMin + t * step;
ImPlot::SetupAxisTicks(ImAxis_X1, tickX, 11, nullptr, false);
}
ImPlot::SetupFinish();
/* ── Custom oscilloscope input handling (after SetupFinish) ─────── *
*
* Scroll → Y zoom : active signal V/div × factor
* Ctrl + Scroll → X zoom : windowSec_ (live) or stored X range (non-live)
* Shift + Scroll → Y pan : active signal screenPos shift
* Right-drag → X pan : (non-live) translate stored X range
*
* Right-drag while live → transitions to non-live mode first. */
/* With ImPlotFlags_NoInputs ImPlot never updates plot.Hovered, so
* IsPlotHovered()/IsAxisHovered() always return false — test the
* plot rectangle against the mouse ourselves. */
const ImVec2 ppos = ImPlot::GetPlotPos();
const ImVec2 psz = ImPlot::GetPlotSize();
bool overPlot = ImGui::IsWindowHovered(
ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
ImGui::IsMouseHoveringRect(
ppos, ImVec2(ppos.x + psz.x, ppos.y + psz.y));
/* Zoom-history debounce: coalesce rapid scroll steps into one entry */
static double lastHistPush[kPlotSlotsMax] = {};
if (overPlot) {
ImGuiIO& io2 = ImGui::GetIO();
const float wheel = io2.MouseWheel;
const bool ctrl = io2.KeyCtrl;
const bool shift = io2.KeyShift;
const double now = ImGui::GetTime();
/* Helper: enter zoomed mode for trigger view (seed from capture window) */
auto enterTrigZoom = [&]() {
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 (trigRel) { enterTrigZoom(); }
if (now - lastHistPush[plotIdx] > 0.6) {
app.pushZoomHist(plotIdx);
lastHistPush[plotIdx] = now;
}
double cx = (app.plotXMin(plotIdx) + app.plotXMax(plotIdx)) * 0.5;
double half = (app.plotXMax(plotIdx) - app.plotXMin(plotIdx)) * 0.5 * factor;
app.setPlotX(plotIdx, cx - half, cx + half);
};
if (wheel != 0.f) {
const double zoomIn = 0.8;
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 (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor);
} else {
xZoomStored(factor);
}
} else if (shift) {
/* ── Y pan ───────────────────────────────────────────── */
if (wheelVS != static_cast<VScale *>(0)) {
latchManual(*wheelVS);
wheelVS->screenPos += (wheel > 0.f) ? 0.5 : -0.5;
}
} else {
/* ── 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 (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor);
} else {
xZoomStored(factor);
}
}
}
}
/* Right-drag → X pan. Transition live→non-live on drag start;
* in trigger view, enter trigger-zoom mode. */
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
if (trigRel) { enterTrigZoom(); }
if (!trigRel && live) {
app.initPlotX(plotIdx, wallNow);
live = false;
lastHistPush[plotIdx] = now;
}
ImVec2 delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Right, 0.f);
ImGui::ResetMouseDragDelta(ImGuiMouseButton_Right);
ImVec2 pxSize = ImPlot::GetPlotSize();
if (pxSize.x > 0.f) {
double xRange = app.plotXMax(plotIdx) - app.plotXMin(plotIdx);
double dt = -(double)delta.x / (double)pxSize.x * xRange;
app.setPlotX(plotIdx,
app.plotXMin(plotIdx) + dt,
app.plotXMax(plotIdx) + dt);
}
}
}
/* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */
if (!trigRel && !paused) {
std::string csv;
for (const auto& a : slots) {
std::string k = app.slotKey(a);
if (k.empty()) continue;
if (!csv.empty()) csv += ",";
csv += k;
}
if (live && !paused && app.windowSec() <= kLiveHiResMaxWin) {
/* Live, zoomed in: continuously refresh the window with a
* fresh hi-res slice from the hub raw ring (throttled). */
static double lastLiveReq[kPlotSlotsMax] = {};
const double now = ImGui::GetTime();
if (!csv.empty() && !zc.pending &&
now - lastLiveReq[plotIdx] > 0.25) {
app.requestZoom(plotIdx, wallNow - app.windowSec(),
wallNow, csv);
lastLiveReq[plotIdx] = now;
}
} else if (!live) {
/* Non-live: debounced — every zoom/pan settles into one
* request, so each zoom level gets fresh resolution.
* If the range is covered by the in-memory ring, use regular
* zoom; otherwise try historyZoom (disk-backed). */
static double rangeChangedAt[kPlotSlotsMax] = {};
static double lastT0[kPlotSlotsMax] = {}, lastT1[kPlotSlotsMax] = {};
const double now = ImGui::GetTime();
double t0 = app.plotXMin(plotIdx), t1 = app.plotXMax(plotIdx);
if (t0 != lastT0[plotIdx] || t1 != lastT1[plotIdx]) {
lastT0[plotIdx] = t0;
lastT1[plotIdx] = t1;
rangeChangedAt[plotIdx] = now;
} else if (rangeChangedAt[plotIdx] > 0.0 &&
now - rangeChangedAt[plotIdx] > 0.35 &&
!csv.empty()) {
/* Try regular zoom first */
if (!zc.pending && !(zc.valid && zc.t0 == t0 && zc.t1 == t1)) {
app.requestZoom(plotIdx, t0, t1, csv);
}
/* Also try history zoom if history is available */
const auto& hi = app.historyInfo();
if (hi.enabled && !hc.pending &&
!(hc.valid && hc.t0 == t0 && hc.t1 == t1)) {
app.requestHistoryZoom(plotIdx, t0, t1, csv);
}
rangeChangedAt[plotIdx] = 0.0;
}
}
}
/* ── Clip data to visible X range before LTTB ─────────────────── *
* The ring buffer may hold far more data than the visible window.
* LTTB must operate only on the visible slice so it picks the best
* 2400 points for what's actually on screen. */
if (xMax > xMin) {
for (size_t si = 0; si < slots.size(); si++) {
auto& tv = tStore[si];
auto& vv = vStore[si];
if (tv.empty()) continue;
auto lo = std::lower_bound(tv.begin(), tv.end(), xMin);
auto hi = std::upper_bound(lo, tv.end(), xMax);
if (lo != tv.begin()) --lo; /* keep one sample before window */
if (hi != tv.end()) ++hi; /* keep one sample after window */
size_t i0 = (size_t)(lo - tv.begin());
size_t i1 = (size_t)(hi - tv.begin());
if (i0 > 0 || i1 < tv.size()) {
tv = std::vector<double>(tv.begin() + i0, tv.begin() + i1);
vv = std::vector<double>(vv.begin() + i0, vv.begin() + i1);
}
}
}
/* ── Plot each signal ──────────────────────────────────────────── */
static const size_t kMaxPush = 2400;
static thread_local std::vector<double> tDec, vDec, vNorm;
int nTraces = 0;
for (const auto& a : slots) {
if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) nTraces++;
}
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;
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
int myKi = ki++;
if (tStore[si].empty()) continue;
size_t nOut = LTTBDecimate(tStore[si], vStore[si], tDec, vDec, kMaxPush);
if (nOut == 0) continue;
/* normalise to ±4 div space */
if (vMode == 1) { /* digital */
bandNormalize(vDec, vNorm, myKi, nTraces, true);
} 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], nvs);
}
}
ImPlot::SetNextLineStyle(sig.color, sig.lineWidth);
if (sig.marker >= 0) {
ImPlot::SetNextMarkerStyle(sig.marker, 3.f, sig.color);
}
ImPlot::PlotLine(sig.meta.name.c_str(),
tDec.data(), vNorm.data(), (int)nOut);
}
/* Trigger instant marker (capture view: t = 0) */
if (trigRel) {
double t0m = 0.0;
ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f),
1.5f, ImPlotDragToolFlags_NoInputs);
}
/* Cursors A/B — global: same position on every plot, drag anywhere.
* Re-seed to visible range if both cursors are outside the view
* (e.g. switching between live and trigger mode). */
if (app.cursorsOn()) {
bool aBeyond = (app.cursorA() < xMin || app.cursorA() > xMax);
bool bBeyond = (app.cursorB() < xMin || app.cursorB() > xMax);
if (aBeyond && bBeyond) {
double range = xMax - xMin;
app.cursorA() = xMin + range * 0.25;
app.cursorB() = xMin + range * 0.75;
}
static const ImVec4 kCursA{0.980f,0.702f,0.529f,0.9f};
static const ImVec4 kCursB{0.796f,0.651f,0.969f,0.9f};
ImPlot::DragLineX(910, &app.cursorA(), kCursA, 1.2f);
ImPlot::DragLineX(911, &app.cursorB(), kCursB, 1.2f);
ImPlot::TagX(app.cursorA(), kCursA, "A");
ImPlot::TagX(app.cursorB(), kCursB, "B");
}
/* Drag-and-drop target */
if (ImPlot::BeginDragDropTargetPlot()) {
if (const ImGuiPayload* pl =
ImGui::AcceptDragDropPayload("SIGNAL_REF")) {
DragPayload dp;
memcpy(&dp, pl->Data, sizeof(dp));
bool found = false;
for (const auto& a : slots) {
if (a.sourceIdx == dp.srcIdx && a.signalIdx == dp.sigIdx) {
found = true; break;
}
}
if (!found) {
PlotAssignment pa;
pa.sourceIdx = dp.srcIdx;
pa.signalIdx = dp.sigIdx;
slots.push_back(pa);
}
}
ImPlot::EndDragDropTarget();
}
ImPlot::EndPlot();
}
ImGui::PopID();
}
} /* namespace StreamHubClient */