/** * @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 #include #include #include #include #include #include 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& 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& 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& raw, std::vector& 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& t, const std::vector& 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(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( 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 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> 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 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 */