Files
2026-06-26 09:11:10 +02:00

893 lines
38 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;
}
/** 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 && !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 = !trigView && !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 (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 (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 / Reset (zoom history) */
if (!live || (trigView && 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 (trigView) {
/* 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", "D", "M"};
static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"};
for (int vm = 0; vm < 3; vm++) {
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm);
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[vm]); }
if (vm < 2) { ImGui::SameLine(0.f, 1.f); }
}
}
/* ── 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 (zoomable); live → wall clock; else stored */
double xMin, xMax;
bool& trigZm = app.trigZoomed(plotIdx);
if (trigView) {
if (trigZm) {
xMin = app.plotXMin(plotIdx);
xMax = app.plotXMax(plotIdx);
} else {
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) {
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 (trigView && !trigZm) {
app.setPlotX(plotIdx, -cap->preSec, cap->postSec);
trigZm = true;
}
};
/* Helper: X-zoom the stored range by factor around center */
auto xZoomStored = [&](double factor) {
if (trigView) { 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;
if (ctrl) {
/* ── X zoom ─────────────────────────────────────────── */
if (!trigView && live) {
app.setWindowSec(app.windowSec() * factor);
} else {
xZoomStored(factor);
}
} else if (shift) {
/* ── Y offset of active signal ───────────────────────── */
if (actSlot >= 0 && actSlot < (int)slots.size()) {
auto& a = slots[actSlot];
if (a.vs.mode != 2) {
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
a.vs.offset = a.vs.resolvedOffset;
a.vs.mode = 2;
}
a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5;
}
} 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 → X zoom */
if (!trigView && 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 (trigView) { enterTrigZoom(); }
if (!trigView && 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 (!trigView && !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 {
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.
* 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 */