Files
MARTe-Integrated-Components/Client/streamhub/PlotPanel.cpp
T
2026-06-14 14:09:51 +02:00

804 lines
34 KiB
C++
Raw 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. */
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));
/* History zoom cache (disk-backed, for ranges beyond in-memory ring) */
auto& hc = app.histZoomCache(plotIdx);
const bool useHistData = !trigView && !live && !useZoomData &&
hc.valid &&
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
hc.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 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]);
}
/* 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);
}
}
}
/* History indicator */
if (useHistData) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f),
ICON_FA_CLOCK_ROTATE_LEFT " HIST");
}
/* 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.
* 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;
}
}
}
/* ── 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 */