Implemented new C++ logic
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
/**
|
||||
* @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. */
|
||||
static const double kLiveHiResMaxWin = 2.0; /* seconds */
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
const CaptureFrame* cap = app.capture();
|
||||
const bool trigView = (cap != nullptr) && app.showTrigBar();
|
||||
|
||||
/* 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 = !trigView && live && !paused &&
|
||||
app.windowSec() <= kLiveHiResMaxWin &&
|
||||
zc.valid &&
|
||||
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
|
||||
(wallNow - zc.t1) < 3.0;
|
||||
|
||||
const bool useZoomData = !trigView && zc.valid &&
|
||||
(liveHiRes ||
|
||||
(!live &&
|
||||
zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
|
||||
zc.t1 >= app.plotXMax(plotIdx) - 1e-9));
|
||||
|
||||
/* 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.readLast((size_t)app.maxPoints(), 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 (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 {
|
||||
readBase(si, sig, key);
|
||||
}
|
||||
resolveVScale(a, sig, vStore[si]);
|
||||
}
|
||||
|
||||
/* 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 vscale info: resolved div value */
|
||||
char dvbuf[16];
|
||||
fmtVal(dvbuf, sizeof(dvbuf), 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 (zoom history) — only meaningful when not live */
|
||||
if (!live && !trigView) {
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Norm mode + cursors toggles */
|
||||
ImGui::SameLine();
|
||||
{
|
||||
static const char* kVModes[] = {"Norm", "Dig", "Mix"};
|
||||
ImGui::SetNextItemWidth(58.f);
|
||||
char vmId[16]; snprintf(vmId, sizeof(vmId), "##vm%d", plotIdx);
|
||||
ImGui::Combo(vmId, &vMode, kVModes, 3);
|
||||
}
|
||||
|
||||
/* ── VScale toolbar (shown when an active signal is selected) ───────── */
|
||||
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
auto& a = slots[actSlot];
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f));
|
||||
|
||||
/* mode buttons */
|
||||
static const char* kModeLabels[] = {"Auto","Range","Manual"};
|
||||
for (int m = 0; m < 3; m++) {
|
||||
bool sel = (a.vs.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 (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), a.vs.resolvedDiv);
|
||||
fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset);
|
||||
|
||||
if (a.vs.mode == 2) { /* manual: editable */
|
||||
ImGui::SetNextItemWidth(70.f);
|
||||
ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g");
|
||||
ImGui::SameLine(0.f,4.f);
|
||||
ImGui::SetNextItemWidth(80.f);
|
||||
ImGui::InputDouble("Offset##vo", &a.vs.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;
|
||||
if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
|
||||
a.vs.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(trigView ? "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; live → wall clock; else stored */
|
||||
double xMin, xMax;
|
||||
if (trigView) {
|
||||
xMin = -cap->preSec; xMax = cap->postSec;
|
||||
} 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 (trigView || (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];
|
||||
|
||||
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
const auto& av = slots[actSlot].vs;
|
||||
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 && !trigView) {
|
||||
ImGuiIO& io2 = ImGui::GetIO();
|
||||
const float wheel = io2.MouseWheel;
|
||||
const bool ctrl = io2.KeyCtrl;
|
||||
const bool shift = io2.KeyShift;
|
||||
const double now = ImGui::GetTime();
|
||||
|
||||
if (wheel != 0.f) {
|
||||
/* Zoom factor: scroll up = zoom in (×0.8), down = zoom out (×1.25) */
|
||||
const double zoomIn = 0.8;
|
||||
const double zoomOut = 1.25;
|
||||
double factor = (wheel > 0.f) ? zoomIn : zoomOut;
|
||||
|
||||
if (ctrl) {
|
||||
/* ── X zoom ─────────────────────────────────────────── */
|
||||
if (live) {
|
||||
app.setWindowSec(app.windowSec() * factor);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
} else if (shift) {
|
||||
/* ── Y offset of active signal ───────────────────────── */
|
||||
if (actSlot >= 0 && actSlot < (int)slots.size()) {
|
||||
auto& a = slots[actSlot];
|
||||
/* Switch to manual so position is retained */
|
||||
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;
|
||||
}
|
||||
/* Each scroll step moves by 0.5 screen divisions */
|
||||
a.vs.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);
|
||||
} else {
|
||||
/* No active signal: plain scroll falls back to X zoom
|
||||
* so the wheel always does something useful. */
|
||||
if (live) {
|
||||
app.setWindowSec(app.windowSec() * factor);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Right-drag → X pan (non-live). Transition live→non-live on drag start. */
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
|
||||
if (live) {
|
||||
/* Seed stored range from current live window */
|
||||
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 ────────────────────────────────────── */
|
||||
if (!trigView) {
|
||||
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. */
|
||||
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 &&
|
||||
!zc.pending &&
|
||||
!(zc.valid && zc.t0 == t0 && zc.t1 == t1)) {
|
||||
if (!csv.empty()) { app.requestZoom(plotIdx, t0, t1, csv); }
|
||||
rangeChangedAt[plotIdx] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
vNorm.resize(nOut);
|
||||
for (size_t k = 0; k < nOut; k++) {
|
||||
vNorm[k] = normalizeY(vDec[k], a.vs);
|
||||
}
|
||||
}
|
||||
|
||||
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 (trigView) {
|
||||
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 */
|
||||
if (app.cursorsOn()) {
|
||||
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 */
|
||||
Reference in New Issue
Block a user