Implemented client datasource

This commit is contained in:
Martino Ferrari
2026-06-25 00:45:45 +02:00
parent dca4872976
commit 0412c20edd
28 changed files with 3448 additions and 618 deletions
+322 -115
View File
@@ -75,9 +75,21 @@ void App::update() {
if (nowConnected && !prevConnected_) {
ws_.sendText(BuildGetSources());
ws_.sendText(BuildGetStats());
ws_.sendText(BuildHistoryInfo());
}
prevConnected_ = nowConnected;
/* Re-request history info periodically until signals are populated
* (the hub opens history files after the first data packet, which is
* after our initial connect-time query). */
if (nowConnected && historyInfo_.enabled && historyInfo_.signals.empty()) {
double now = ImGui::GetTime();
if (now - lastHistInfoReq_ > 2.0) {
ws_.sendText(BuildHistoryInfo());
lastHistInfoReq_ = now;
}
}
/* Drain WebSocket receive queue */
ws_.poll([this](const WSMessage& msg) {
if (msg.isBinary) {
@@ -99,8 +111,7 @@ void App::update() {
| ImGuiWindowFlags_NoResize
| ImGuiWindowFlags_NoMove
| ImGuiWindowFlags_NoBringToFrontOnFocus
| ImGuiWindowFlags_NoNavFocus
| ImGuiWindowFlags_MenuBar;
| ImGuiWindowFlags_NoNavFocus;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
@@ -108,8 +119,8 @@ void App::update() {
ImGui::Begin("##main", nullptr, wf);
ImGui::PopStyleVar(3);
renderMenuBar();
renderToolbar();
if (showHistBar_) { renderHistoryBar(); }
if (showTrigBar_) { renderTriggerBar(); }
/* Horizontal split: sidebar | plot grid */
@@ -136,90 +147,33 @@ void App::update() {
if (showAddSrc_) { renderAddSourceModal(); }
}
/* ── Menu bar ────────────────────────────────────────────────────────────── */
void App::renderMenuBar() {
if (!ImGui::BeginMenuBar()) { return; }
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Quit", "Alt+F4")) {
/* Signal quit via SDL event — handled in main.cpp via flag */
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("Sidebar", nullptr, &sidebarOpen_);
ImGui::MenuItem("Trigger Bar", nullptr, &showTrigBar_);
ImGui::MenuItem("Statistics", nullptr, &showStats_);
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Sources")) {
if (ImGui::MenuItem(ICON_FA_PLUS " Add Source...")) { showAddSrc_ = true; }
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Layout")) {
for (int i = 0; i < static_cast<int>(PlotLayout::kCount); i++) {
bool sel = (static_cast<int>(layout_) == i);
if (ImGui::MenuItem(PlotLayoutNames[i], nullptr, sel)) {
setLayout(static_cast<PlotLayout>(i));
}
}
ImGui::EndMenu();
}
/* Right-aligned: [host] : [port] [Connect/Reconnect] ● status */
bool connected = ws_.isConnected();
const char* btnLbl = connected ? "Reconnect" : "Connect";
const char* ledLbl = connected ? ICON_FA_CIRCLE " Connected"
: ICON_FA_CIRCLE " Disconnected";
/* Measure widget widths to right-align the whole group */
const ImGuiStyle& st = ImGui::GetStyle();
const float kHostW = 130.f;
const float kPortW = 52.f;
const float kBtnW = ImGui::CalcTextSize(btnLbl).x + st.FramePadding.x * 2.f;
const float kLedW = ImGui::CalcTextSize(ledLbl).x;
const float kGroupW = kHostW + 8.f + kPortW + 8.f + kBtnW + 8.f + kLedW;
ImGui::SetCursorPosX(
ImGui::GetCursorPosX() +
ImGui::GetContentRegionAvail().x - kGroupW);
ImGui::SetNextItemWidth(kHostW);
ImGui::InputText("##hubhost", hostBuf_, sizeof(hostBuf_));
ImGui::SameLine(0.f, 2.f);
ImGui::TextUnformatted(":");
ImGui::SameLine(0.f, 2.f);
ImGui::SetNextItemWidth(kPortW);
ImGui::InputText("##hubport", portBuf_, sizeof(portBuf_),
ImGuiInputTextFlags_CharsDecimal);
ImGui::SameLine(0.f, 6.f);
if (ImGui::Button(btnLbl)) {
ws_.reconnect(hostBuf_, static_cast<uint16_t>(atoi(portBuf_)));
}
ImGui::SameLine(0.f, 8.f);
ImVec4 ledColor = connected
? ImVec4(0.1f, 0.9f, 0.3f, 1.f)
: ImVec4(0.9f, 0.2f, 0.2f, 1.f);
ImGui::TextColored(ledColor, "%s", ledLbl);
ImGui::EndMenuBar();
}
/* renderMenuBar() removed — all functionality merged into toolbar + sidebar */
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
void App::renderToolbar() {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f));
/* Layout picker — pictogram popup */
/* ── Sidebar toggle (leftmost) ───────────────────────────────────────── */
{
if (sidebarOpen_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
}
if (ImGui::Button(ICON_FA_BARS "##sidebar")) { sidebarOpen_ = !sidebarOpen_; }
if (sidebarOpen_) { ImGui::PopStyleColor(); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Toggle sidebar"); }
}
ImGui::SameLine();
/* ── Layout picker — pictogram popup ─────────────────────────────────── */
static const int kIconCols[] = {1,2,1,3,1,2,4,1};
static const int kIconRows[] = {1,1,2,1,3,2,1,4};
static const ImVec2 kIconSz = ImVec2(36.f, 24.f);
if (ImGui::Button(ICON_FA_TABLE_CELLS_LARGE " Layout")) {
if (ImGui::Button(ICON_FA_TABLE_CELLS_LARGE "##layout")) {
ImGui::OpenPopup("##layout_pop");
}
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Layout"); }
ImGui::SameLine();
if (ImGui::BeginPopup("##layout_pop")) {
ImGui::TextDisabled("Select layout");
@@ -228,7 +182,6 @@ void App::renderToolbar() {
for (int li = 0; li < static_cast<int>(PlotLayout::kCount); li++) {
bool sel = (static_cast<int>(layout_) == li);
ImVec2 p = ImGui::GetCursorScreenPos();
/* Invisible button sized to the icon */
if (ImGui::InvisibleButton(PlotLayoutNames[li], kIconSz)) {
setLayout(static_cast<PlotLayout>(li));
ImGui::CloseCurrentPopup();
@@ -242,7 +195,7 @@ void App::renderToolbar() {
ImGui::EndPopup();
}
/* Global pause */
/* ── Global pause ────────────────────────────────────────────────────── */
if (ImGui::Button(globalPaused_ ? ICON_FA_PLAY " Resume"
: ICON_FA_PAUSE " Pause")) {
globalPaused_ = !globalPaused_;
@@ -253,45 +206,77 @@ void App::renderToolbar() {
}
ImGui::SameLine();
/* Cursors A/B (global: shared & synchronised across all plots) */
/* ── Cursors A/B toggle ──────────────────────────────────────────────── */
{
if (cursorsOn_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
}
if (ImGui::Button(ICON_FA_CROSSHAIRS " Cursors")) {
cursorsOn_ = !cursorsOn_;
if (cursorsOn_) {
/* seed cursors at 25% / 75% of the live window */
const double wallNow = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count();
const double x0 = wallNow - windowSec_;
cursorA_ = x0 + windowSec_ * 0.25;
cursorB_ = x0 + windowSec_ * 0.75;
}
}
if (ImGui::Button(ICON_FA_CROSSHAIRS "##curs")) { cursorsOn_ = !cursorsOn_; }
if (cursorsOn_) { ImGui::PopStyleColor(); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Cursors A/B"); }
}
ImGui::SameLine();
/* Trigger bar toggle */
if (ImGui::Button(ICON_FA_BOLT " Trigger")) { showTrigBar_ = !showTrigBar_; }
/* ── Trigger bar toggle ──────────────────────────────────────────────── */
{
if (showTrigBar_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
}
if (ImGui::Button(ICON_FA_BOLT "##trig")) { showTrigBar_ = !showTrigBar_; }
if (showTrigBar_) { ImGui::PopStyleColor(); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Trigger"); }
}
ImGui::SameLine();
/* Stats */
if (ImGui::Button(ICON_FA_CHART_COLUMN " Stats")) { showStats_ = !showStats_; }
/* ── History browse ──────────────────────────────────────────────────── */
{
bool histAvail = historyInfo_.enabled && !historyInfo_.signals.empty();
if (!histAvail) { ImGui::BeginDisabled(); }
if (showHistBar_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
}
if (ImGui::Button(ICON_FA_CLOCK_ROTATE_LEFT "##hist")) {
if (histAvail) {
showHistBar_ = !showHistBar_;
if (showHistBar_) {
/* On first open, jump to the full history range */
double ht0 = 1e300, ht1 = -1e300;
for (const auto& hs : historyInfo_.signals) {
if (hs.t0 < ht0) ht0 = hs.t0;
if (hs.t1 > ht1) ht1 = hs.t1;
}
if (ht1 > ht0) {
for (int i = 0; i < numPlotSlots(); i++) {
liveFollow_[i] = false;
setPlotX(i, ht0, ht1);
zoomCache_[i].valid = false;
zoomCache_[i].pending = false;
histZoomCache_[i].valid = false;
histZoomCache_[i].pending = false;
zoomHist_[i].clear();
}
}
} else {
/* Closing the bar → back to live */
for (int i = 0; i < numPlotSlots(); i++) {
liveFollow_[i] = true;
}
}
}
}
if (showHistBar_) { ImGui::PopStyleColor(); }
if (!histAvail) { ImGui::EndDisabled(); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("History browse"); }
}
ImGui::SameLine();
/* Add source */
if (ImGui::Button(ICON_FA_PLUS " Source")) { showAddSrc_ = true; }
ImGui::SameLine();
/* Time window presets (Ctrl+scroll on a plot still adjusts freely) */
/* ── Time window presets ─────────────────────────────────────────────── */
static const double kWinPresets[] = {1.0, 5.0, 10.0, 30.0, 60.0};
static const char* kWinLabels[] = {"1 s", "5 s", "10 s", "30 s", "60 s"};
char winPreview[16];
snprintf(winPreview, sizeof(winPreview), "%.3g s", windowSec_);
ImGui::SetNextItemWidth(70.f);
if (ImGui::BeginCombo("Win", winPreview)) {
if (ImGui::BeginCombo("##win", winPreview)) {
for (int wi = 0; wi < 5; wi++) {
bool sel = std::fabs(windowSec_ - kWinPresets[wi]) < 1e-9;
if (ImGui::Selectable(kWinLabels[wi], sel)) {
@@ -301,18 +286,213 @@ void App::renderToolbar() {
}
ImGui::EndCombo();
}
/* ── Right-aligned group: [Stats] [Connection ▼] ● LED ──────────────── */
bool connected = ws_.isConnected();
const char* connLabel = connected
? ICON_FA_CIRCLE " Connected"
: ICON_FA_CIRCLE " Disconnected";
const ImGuiStyle& st = ImGui::GetStyle();
const float kStatsW = ImGui::CalcTextSize(ICON_FA_CHART_COLUMN).x
+ st.FramePadding.x * 2.f;
const float kConnBtnW = ImGui::CalcTextSize(ICON_FA_LINK).x
+ st.FramePadding.x * 2.f;
const float kLedW = ImGui::CalcTextSize(connLabel).x + 8.f;
const float kGroupW = kStatsW + 4.f + kConnBtnW + 4.f + kLedW;
float rightX = ImGui::GetCursorPosX() +
ImGui::GetContentRegionAvail().x - kGroupW;
if (rightX > ImGui::GetCursorPosX()) {
ImGui::SameLine(rightX);
} else {
ImGui::SameLine();
}
/* Stats toggle */
{
if (showStats_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
}
if (ImGui::Button(ICON_FA_CHART_COLUMN "##stats")) { showStats_ = !showStats_; }
if (showStats_) { ImGui::PopStyleColor(); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Statistics"); }
}
ImGui::SameLine(0.f, 4.f);
/* Connection dropdown */
if (ImGui::Button(ICON_FA_LINK "##conn")) {
ImGui::OpenPopup("##conn_pop");
}
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Connection"); }
if (ImGui::BeginPopup("##conn_pop")) {
ImGui::TextDisabled("Hub Connection");
ImGui::Separator();
ImGui::SetNextItemWidth(160.f);
ImGui::InputText("Host##hubhost", hostBuf_, sizeof(hostBuf_));
ImGui::SetNextItemWidth(80.f);
ImGui::InputText("Port##hubport", portBuf_, sizeof(portBuf_),
ImGuiInputTextFlags_CharsDecimal);
ImGui::Spacing();
const char* btnLbl = connected ? "Reconnect" : "Connect";
if (ImGui::Button(btnLbl, ImVec2(120.f, 0.f))) {
ws_.reconnect(hostBuf_, static_cast<uint16_t>(atoi(portBuf_)));
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::SameLine(0.f, 4.f);
/* Status LED */
ImVec4 ledColor = connected
? ImVec4(0.1f, 0.9f, 0.3f, 1.f)
: ImVec4(0.9f, 0.2f, 0.2f, 1.f);
ImGui::TextColored(ledColor, "%s", connLabel);
ImGui::PopStyleVar();
ImGui::Separator();
}
/* ── History browsing toolbar ────────────────────────────────────────────── */
void App::renderHistoryBar() {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.980f,0.702f,0.529f,1.f));
/* Title */
ImGui::TextUnformatted(ICON_FA_CLOCK_ROTATE_LEFT " History");
ImGui::PopStyleColor();
ImGui::SameLine();
/* Max points control */
ImGui::SetNextItemWidth(80.f);
int mp = static_cast<int>(maxPoints_);
if (ImGui::InputInt("MaxPts", &mp, 0, 0,
ImGuiInputTextFlags_EnterReturnsTrue)) {
if (mp >= 2) {
maxPoints_ = static_cast<uint32_t>(mp);
ws_.sendText(BuildSetMaxPoints(maxPoints_));
/* ── Live button ── */
if (ImGui::SmallButton(ICON_FA_TOWER_BROADCAST " Live")) {
showHistBar_ = false;
for (int i = 0; i < numPlotSlots(); i++) {
liveFollow_[i] = true;
}
ImGui::PopStyleVar();
return;
}
ImGui::SameLine();
ImGui::TextDisabled("|"); ImGui::SameLine();
/* ── Current time range display ── */
double t0 = 0.0, t1 = 0.0;
bool haveRange = false;
for (int i = 0; i < numPlotSlots(); i++) {
if (!liveFollow_[i]) {
t0 = plotXMin_[i]; t1 = plotXMax_[i];
haveRange = true;
break;
}
}
const double wallNow = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (haveRange) {
double span = t1 - t0;
double ago = wallNow - t1;
char rangeLabel[128];
if (ago < 1.0) {
snprintf(rangeLabel, sizeof(rangeLabel),
"%.3g s span | now", span);
} else if (ago < 60.0) {
snprintf(rangeLabel, sizeof(rangeLabel),
"%.3g s span | %.0f s ago", span, ago);
} else if (ago < 3600.0) {
snprintf(rangeLabel, sizeof(rangeLabel),
"%.3g s span | %.1f min ago", span, ago / 60.0);
} else {
snprintf(rangeLabel, sizeof(rangeLabel),
"%.3g s span | %.1f h ago", span, ago / 3600.0);
}
ImGui::TextDisabled("%s", rangeLabel);
} else {
ImGui::TextDisabled("No plot range");
}
ImGui::SameLine();
/* ── Navigation: pan ← → ── */
if (ImGui::SmallButton(ICON_FA_CHEVRON_LEFT "##hleft")) {
for (int i = 0; i < numPlotSlots(); i++) {
if (!liveFollow_[i]) {
double span = plotXMax_[i] - plotXMin_[i];
setPlotX(i, plotXMin_[i] - span * 0.75, plotXMax_[i] - span * 0.75);
}
}
}
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Pan left (75%% of window)"); }
ImGui::SameLine(0.f, 2.f);
if (ImGui::SmallButton(ICON_FA_CHEVRON_RIGHT "##hright")) {
for (int i = 0; i < numPlotSlots(); i++) {
if (!liveFollow_[i]) {
double span = plotXMax_[i] - plotXMin_[i];
double newMax = plotXMax_[i] + span * 0.75;
/* Clamp: don't go past wallNow */
if (newMax > wallNow) {
newMax = wallNow;
double newMin = newMax - span;
setPlotX(i, newMin, newMax);
} else {
setPlotX(i, plotXMin_[i] + span * 0.75, newMax);
}
}
}
}
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Pan right (75%% of window)"); }
ImGui::SameLine();
ImGui::TextDisabled("|"); ImGui::SameLine();
/* ── Jump presets ── */
static const double kJumpSec[] = { 10.0, 30.0, 60.0,
300.0, 600.0, 1800.0, 3600.0};
static const char* kJumpLabel[] = {"10 s", "30 s", "1 min",
"5 min","10 min","30 min","1 h"};
for (int j = 0; j < 7; j++) {
char jid[32];
snprintf(jid, sizeof(jid), "%s##hj%d", kJumpLabel[j], j);
if (ImGui::SmallButton(jid)) {
for (int i = 0; i < numPlotSlots(); i++) {
liveFollow_[i] = false;
double span = plotXMax_[i] - plotXMin_[i];
if (span <= 0.0) { span = windowSec_; }
setPlotX(i, wallNow - kJumpSec[j] - span,
wallNow - kJumpSec[j]);
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Jump to %s ago", kJumpLabel[j]);
}
if (j < 6) { ImGui::SameLine(0.f, 2.f); }
}
ImGui::SameLine();
/* ── All history ── */
bool canAll = historyInfo_.enabled && !historyInfo_.signals.empty();
if (!canAll) { ImGui::BeginDisabled(); }
if (ImGui::SmallButton("All")) {
double ht0 = 1e300, ht1 = -1e300;
for (const auto& hs : historyInfo_.signals) {
if (hs.t0 < ht0) ht0 = hs.t0;
if (hs.t1 > ht1) ht1 = hs.t1;
}
if (ht1 > ht0) {
for (int i = 0; i < numPlotSlots(); i++) {
liveFollow_[i] = false;
setPlotX(i, ht0, ht1);
zoomCache_[i].valid = false;
zoomCache_[i].pending = false;
histZoomCache_[i].valid = false;
histZoomCache_[i].pending = false;
zoomHist_[i].clear();
}
}
}
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Show all available history"); }
if (!canAll) { ImGui::EndDisabled(); }
ImGui::PopStyleVar();
ImGui::Separator();
@@ -521,6 +701,7 @@ void App::handleBinary(const uint8_t* data, size_t len) {
trigger_.status = "triggered";
trigger_.trigTime = capture_.trigTime;
trigger_.hasTrigTime = true;
resetTrigZoom();
}
return;
}
@@ -660,10 +841,24 @@ void App::onZoom(const std::string& json) {
for (int i = 0; i < kMaxPlotSlots; i++) {
auto& zc = zoomCache_[i];
if (zc.pending && zc.reqId == resp.reqId) {
zc.signals = std::move(resp.signals);
zc.t0 = zc.reqT0;
zc.t1 = zc.reqT1;
zc.valid = true;
/* Compute the actual time range from the returned data so the
* coverage check in PlotPanel can distinguish between "ring had
* data for the full window" and "ring only had partial data". */
double dataT0 = 1e300, dataT1 = -1e300;
bool anyData = false;
for (const auto& zs : resp.signals) {
for (double tv : zs.t) {
if (tv < dataT0) { dataT0 = tv; }
if (tv > dataT1) { dataT1 = tv; }
anyData = true;
}
}
if (anyData) {
zc.signals = std::move(resp.signals);
zc.t0 = dataT0;
zc.t1 = dataT1;
zc.valid = true;
}
zc.pending = false;
return;
}
@@ -717,10 +912,22 @@ void App::onHistoryZoom(const std::string& json) {
for (int i = 0; i < kMaxPlotSlots; i++) {
auto& hc = histZoomCache_[i];
if (hc.pending && hc.reqId == resp.reqId) {
hc.signals = std::move(resp.signals);
hc.t0 = hc.reqT0;
hc.t1 = hc.reqT1;
hc.valid = true;
/* Compute the actual time range from returned data. */
double dataT0 = 1e300, dataT1 = -1e300;
bool anyData = false;
for (const auto& zs : resp.signals) {
for (double tv : zs.t) {
if (tv < dataT0) { dataT0 = tv; }
if (tv > dataT1) { dataT1 = tv; }
anyData = true;
}
}
if (anyData) {
hc.signals = std::move(resp.signals);
hc.t0 = dataT0;
hc.t1 = dataT1;
hc.valid = true;
}
hc.pending = false;
return;
}
+13 -3
View File
@@ -26,7 +26,7 @@ namespace StreamHubClient {
/** One signal as known to the client. */
struct Signal {
SignalMeta meta;
SignalBuffer buf{20000};
SignalBuffer buf{1000000};
ImVec4 color{0.4f, 0.8f, 1.0f, 1.0f};
float lineWidth = 1.5f;
int marker = -1; /* ImPlotMarker (-1 = none) */
@@ -244,10 +244,15 @@ public:
/** @brief Hi-res history zoom cache (per plot, parallel to zoomCache_). */
PlotZoomCache& histZoomCache(int i) { return histZoomCache_[i]; }
/* ---- Trigger zoom (per plot, used in trigger capture view) ----------- */
bool& trigZoomed(int i) { return trigZoomed_[i]; }
void resetTrigZoom() { for (int i = 0; i < kMaxPlotSlots; i++) trigZoomed_[i] = false; }
/** @brief True if the stats panel is open. */
bool& showStats() { return showStats_; }
bool& showAddSrc() { return showAddSrc_; }
bool& showTrigBar() { return showTrigBar_; }
bool& showHistBar() { return showHistBar_; }
/** @brief Find source index by id (-1 if not found). */
int findSource(const std::string& id) const;
@@ -270,8 +275,8 @@ private:
void onMaxPointsUpdated(const std::string& json);
/* ---- Rendering ------------------------------------------------------ */
void renderMenuBar();
void renderToolbar();
void renderHistoryBar();
void renderTriggerBar();
void renderSidebar();
void renderPlotGrid();
@@ -285,7 +290,7 @@ private:
TriggerState trigger_;
PlotLayout layout_ = PlotLayout::kLayout1x1;
uint32_t maxPoints_ = 20000u;
uint32_t maxPoints_ = 1000000u;
bool globalPaused_ = false;
/* Plot slots: one vector of assignments per plot panel */
@@ -304,6 +309,9 @@ private:
double cursorA_ = 0.0;
double cursorB_ = 0.0;
/* Trigger zoom per plot (capture view zoom state) */
bool trigZoomed_[kMaxPlotSlots] = {};
/* Frozen view per plot (valid while the plot is paused) */
PlotSnapshot plotSnap_[kMaxPlotSlots];
@@ -315,11 +323,13 @@ private:
/* History info from the hub */
HistoryInfoMsg historyInfo_;
double lastHistInfoReq_ = 0.0;
/* UI state */
bool showStats_ = false;
bool showAddSrc_ = false;
bool showTrigBar_ = false;
bool showHistBar_ = false;
bool sidebarOpen_ = true;
/* Connection fields (populated by init(), editable in menu bar) */
+129 -54
View File
@@ -39,8 +39,10 @@ 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 */
* 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 ─────────────────────────────────────────────────────────────── */
@@ -198,18 +200,30 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
(wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigView && zc.valid &&
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) */
/* 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 useHistData = !trigView && !live && !useZoomData &&
hc.valid &&
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
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; }
}
/* 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) {
@@ -369,8 +383,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
ImGui::PopStyleColor(2);
}
/* Back / Fit (zoom history) — only meaningful when not live */
if (!live && !trigView) {
/* Back / Fit / Reset (zoom history) */
if (!live || (trigView && app.trigZoomed(plotIdx))) {
ImGui::SameLine();
auto& hist = app.zoomHist(plotIdx);
if (hist.empty()) { ImGui::BeginDisabled(); }
@@ -380,16 +394,24 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
}
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 (trigView) {
/* Reset to full capture window */
if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
app.trigZoomed(plotIdx) = false;
app.zoomHist(plotIdx).clear();
}
if (mx > mn) {
app.pushZoomHist(plotIdx);
app.setPlotX(plotIdx, mn, mx);
} 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);
}
}
}
}
@@ -401,13 +423,23 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
ICON_FA_CLOCK_ROTATE_LEFT " HIST");
}
/* Norm mode + cursors toggles */
/* Norm/Dig/Mix mode compact toggle buttons matching SmallButton height */
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);
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) ───────── */
@@ -500,10 +532,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* 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 */
/* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
double xMin, xMax;
bool& trigZm = app.trigZoomed(plotIdx);
if (trigView) {
xMin = -cap->preSec; xMax = cap->postSec;
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
@@ -574,43 +613,54 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Zoom-history debounce: coalesce rapid scroll steps into one entry */
static double lastHistPush[kPlotSlotsMax] = {};
if (overPlot && !trigView) {
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) {
/* 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) {
if (!trigView && 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);
xZoomStored(factor);
}
} 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 {
@@ -624,27 +674,21 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
}
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) {
/* No active signal: plain scroll X zoom */
if (!trigView && 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);
xZoomStored(factor);
}
}
}
}
/* Right-drag → X pan (non-live). Transition live→non-live on drag start. */
/* Right-drag → X pan. Transition live→non-live on drag start;
* in trigger view, enter trigger-zoom mode. */
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
if (live) {
/* Seed stored range from current live window */
if (trigView) { enterTrigZoom(); }
if (!trigView && live) {
app.initPlotX(plotIdx, wallNow);
live = false;
lastHistPush[plotIdx] = now;
@@ -662,8 +706,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
}
}
/* ── Hi-res WS zoom requests ────────────────────────────────────── */
if (!trigView) {
/* ── 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);
@@ -714,6 +758,28 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
}
}
/* ── 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;
@@ -762,8 +828,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
1.5f, ImPlotDragToolFlags_NoInputs);
}
/* Cursors A/B — global: same position on every plot, drag anywhere */
/* 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);
+2 -3
View File
@@ -553,9 +553,8 @@ bool ParseHistoryInfo(const std::string& json, HistoryInfoMsg& out) {
out.signals.clear();
out.enabled = false;
char tmp[16] = "";
jsonGetStr(json.c_str(), "enabled", tmp, sizeof(tmp));
out.enabled = (strcmp(tmp, "true") == 0);
/* "enabled" is a JSON boolean (unquoted true/false), not a string */
out.enabled = (strstr(json.c_str(), "\"enabled\":true") != nullptr);
double df = 0.0;
jsonGetDouble(json.c_str(), "durationHours", df);
+12
View File
@@ -100,6 +100,18 @@ void RenderSourcePanel(App& app) {
}
ImGui::PopStyleVar();
/* ── Add / Save sources (inline at the bottom of the sidebar) ──────── */
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button(ICON_FA_PLUS " Add Source", ImVec2(-1.f, 0.f))) {
app.showAddSrc() = true;
}
if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save Sources", ImVec2(-1.f, 0.f))) {
app.ws().sendText(BuildSaveSources());
}
}
} /* namespace StreamHubClient */
+30 -45
View File
@@ -5,6 +5,9 @@
* The trigger runs in the C++ StreamHub: this panel only edits the config
* (signal/edge/threshold/window/pre%/mode), sends setTrigger + arm/disarm/
* rearm/trigStop commands, and reflects the hub triggerState broadcasts.
*
* Single-row compact layout: all controls fit on one line on a ≥1280 px window;
* ImGui wraps naturally on narrower screens without a scrollbar.
*/
#include "TriggerPanel.h"
@@ -22,12 +25,10 @@ namespace StreamHubClient {
/* Window presets (mirrors the web UI: 100 µs .. 10 s) */
static const double kWinVals[] = {1e-4, 1e-3, 1e-2, 1e-1, 1.0, 10.0};
static const char* kWinLabels[] = {"100 µs", "1 ms", "10 ms", "100 ms", "1 s", "10 s"};
static const char* kWinLabels[] = {"100 us", "1 ms", "10 ms", "100 ms", "1 s", "10 s"};
static const int kNumWins = 6;
static const char* kEdgeLabels[] = {ICON_FA_ARROW_TREND_UP " Rising",
ICON_FA_ARROW_TREND_DOWN " Falling",
ICON_FA_ARROWS_UP_DOWN " Both"};
static const char* kEdgeLabels[] = {"Rising", "Falling", "Both"};
static const char* kEdgeWire[] = {"rising", "falling", "both"};
/* Send the current config to the hub. */
@@ -44,20 +45,14 @@ void RenderTriggerPanel(App& app) {
auto& trig = app.trigger();
auto& sources = app.sources();
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.15f, 0.15f, 0.20f, 1.f));
ImGui::BeginChild("##trigbar", ImVec2(0.f, 90.f), true);
ImGui::PopStyleColor();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6.f, 4.f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f));
bool cfgChanged = false;
/* ── Signal selector (full keys "src:sig") ─────────────────────────── *
* Vector signals are flattened time-series on the hub: one key per
* signal; the trigger engine checks every decoded sample of it. */
ImGui::SetNextItemWidth(180.f);
if (ImGui::BeginCombo("Signal##trig", trig.signalKey.empty()
? "<select>" : trig.signalKey.c_str())) {
/* ── Signal selector ──────────────────────────────────────────────── */
ImGui::SetNextItemWidth(150.f);
if (ImGui::BeginCombo("##trigsig", trig.signalKey.empty()
? "<signal>" : trig.signalKey.c_str())) {
for (const auto& src : sources) {
for (const auto& sig : src.signals) {
char key[192];
@@ -76,14 +71,14 @@ void RenderTriggerPanel(App& app) {
ImGui::SameLine();
/* ── Edge ─────────────────────────────────────────────────────────── */
ImGui::SetNextItemWidth(95.f);
if (ImGui::Combo("Edge##trig", &trig.edge, kEdgeLabels, 3)) {
ImGui::SetNextItemWidth(70.f);
if (ImGui::Combo("##trigedge", &trig.edge, kEdgeLabels, 3)) {
cfgChanged = true;
}
ImGui::SameLine();
/* ── Threshold ───────────────────────────────────────────────────── */
ImGui::SetNextItemWidth(80.f);
ImGui::SetNextItemWidth(65.f);
if (ImGui::InputDouble("Thr##trig", &trig.threshold, 0.0, 0.0, "%.4g",
ImGuiInputTextFlags_EnterReturnsTrue)) {
cfgChanged = true;
@@ -97,7 +92,7 @@ void RenderTriggerPanel(App& app) {
winIdx = w; break;
}
}
ImGui::SetNextItemWidth(85.f);
ImGui::SetNextItemWidth(75.f);
if (ImGui::Combo("Win##trig", &winIdx, kWinLabels, kNumWins)) {
trig.windowSec = kWinVals[winIdx];
cfgChanged = true;
@@ -105,24 +100,21 @@ void RenderTriggerPanel(App& app) {
ImGui::SameLine();
/* ── Pre-trigger % ───────────────────────────────────────────────── */
ImGui::SetNextItemWidth(100.f);
ImGui::SetNextItemWidth(70.f);
float preP = static_cast<float>(trig.prePercent);
if (ImGui::SliderFloat("Pre%##trig", &preP, 0.f, 100.f, "%.0f%%")) {
if (ImGui::SliderFloat("Pre##trig", &preP, 0.f, 100.f, "%.0f%%")) {
trig.prePercent = static_cast<double>(preP);
}
if (ImGui::IsItemDeactivatedAfterEdit()) { cfgChanged = true; }
ImGui::SameLine();
/* ── Mode ────────────────────────────────────────────────────────── */
ImGui::TextUnformatted("Mode:");
ImGui::SameLine();
int singleInt = trig.single ? 1 : 0;
if (ImGui::RadioButton("Normal", &singleInt, 0)) { cfgChanged = true; }
if (ImGui::RadioButton("Norm##trig", &singleInt, 0)) { cfgChanged = true; }
ImGui::SameLine();
if (ImGui::RadioButton("Single", &singleInt, 1)) { cfgChanged = true; }
if (ImGui::RadioButton("1x##trig", &singleInt, 1)) { cfgChanged = true; }
trig.single = (singleInt != 0);
ImGui::NewLine();
ImGui::SameLine(0.f, 10.f);
/* ── Status badge ────────────────────────────────────────────────── */
ImVec4 badgeColor;
@@ -132,44 +124,38 @@ void RenderTriggerPanel(App& app) {
else if (trig.status == "triggered") { badgeColor = ImVec4(0.2f,0.9f,0.3f,1.f); badgeText = "TRIGGERED"; }
else { badgeColor = ImVec4(0.6f,0.6f,0.6f,1.f); badgeText = "IDLE"; }
ImGui::TextColored(badgeColor, "[%s]", badgeText);
if (trig.hasTrigTime &&
(trig.status == "collecting" || trig.status == "triggered")) {
ImGui::SameLine();
ImGui::TextDisabled("t=%.6f", trig.trigTime);
}
ImGui::SameLine();
/* ── Arm / Disarm / Rearm / Stop ─────────────────────────────────── */
/* ── Arm / Disarm / Stop ────────────────────────────────────────── */
bool canArm = !trig.signalKey.empty();
if (!canArm) { ImGui::BeginDisabled(); }
if (ImGui::Button(ICON_FA_BOLT " Arm")) {
if (ImGui::SmallButton(ICON_FA_BOLT " Arm")) {
sendTrigConfig(app);
app.ws().sendText(BuildArm());
}
if (!canArm) { ImGui::EndDisabled(); }
ImGui::SameLine();
if (ImGui::Button("Disarm")) {
if (ImGui::SmallButton("Disarm")) {
app.ws().sendText(BuildDisarm());
}
ImGui::SameLine();
if (trig.single && trig.status == "triggered") {
if (ImGui::Button("Rearm")) {
app.ws().sendText(BuildRearm());
}
ImGui::SameLine();
}
if (!trig.single) {
if (ImGui::Button(trig.stopped ? ICON_FA_PLAY " Run" : ICON_FA_STOP " Stop")) {
if (ImGui::SmallButton(trig.stopped ? ICON_FA_PLAY " Run" : ICON_FA_STOP " Stop")) {
trig.stopped = !trig.stopped;
app.ws().sendText(BuildTrigStop(trig.stopped));
}
ImGui::SameLine();
}
ImGui::NewLine();
/* Trigger time readout (inline) */
if (trig.hasTrigTime &&
(trig.status == "collecting" || trig.status == "triggered")) {
ImGui::TextDisabled("t=%.6f", trig.trigTime);
} else {
ImGui::NewLine();
}
/* Config edits while armed take effect immediately on the hub */
if (cfgChanged && !trig.signalKey.empty()) {
@@ -177,7 +163,6 @@ void RenderTriggerPanel(App& app) {
}
ImGui::PopStyleVar();
ImGui::EndChild();
ImGui::Separator();
}
Binary file not shown.