Implemented new C++ logic

This commit is contained in:
Martino Ferrari
2026-06-12 15:25:13 +02:00
parent 617b5bd712
commit f25bd7f08e
220 changed files with 39185 additions and 850 deletions
+767
View File
@@ -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 */