diff --git a/Client/streamhub/App.cpp b/Client/streamhub/App.cpp index 6ea8b2c..fe5fe56 100644 --- a/Client/streamhub/App.cpp +++ b/Client/streamhub/App.cpp @@ -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(PlotLayout::kCount); i++) { - bool sel = (static_cast(layout_) == i); - if (ImGui::MenuItem(PlotLayoutNames[i], nullptr, sel)) { - setLayout(static_cast(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(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(PlotLayout::kCount); li++) { bool sel = (static_cast(layout_) == li); ImVec2 p = ImGui::GetCursorScreenPos(); - /* Invisible button sized to the icon */ if (ImGui::InvisibleButton(PlotLayoutNames[li], kIconSz)) { setLayout(static_cast(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( - 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(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(maxPoints_); - if (ImGui::InputInt("MaxPts", &mp, 0, 0, - ImGuiInputTextFlags_EnterReturnsTrue)) { - if (mp >= 2) { - maxPoints_ = static_cast(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( + 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; } diff --git a/Client/streamhub/App.h b/Client/streamhub/App.h index ecd97dc..b4902e2 100644 --- a/Client/streamhub/App.h +++ b/Client/streamhub/App.h @@ -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) */ diff --git a/Client/streamhub/PlotPanel.cpp b/Client/streamhub/PlotPanel.cpp index 6e85b68..115c1d6 100644 --- a/Client/streamhub/PlotPanel.cpp +++ b/Client/streamhub/PlotPanel.cpp @@ -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(tv.begin() + i0, tv.begin() + i1); + vv = std::vector(vv.begin() + i0, vv.begin() + i1); + } + } + } + /* ── Plot each signal ──────────────────────────────────────────── */ static const size_t kMaxPush = 2400; static thread_local std::vector 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); diff --git a/Client/streamhub/Protocol.cpp b/Client/streamhub/Protocol.cpp index 9a21691..bf6ffb3 100644 --- a/Client/streamhub/Protocol.cpp +++ b/Client/streamhub/Protocol.cpp @@ -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); diff --git a/Client/streamhub/SourcePanel.cpp b/Client/streamhub/SourcePanel.cpp index 9f50a20..759dd31 100644 --- a/Client/streamhub/SourcePanel.cpp +++ b/Client/streamhub/SourcePanel.cpp @@ -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 */ diff --git a/Client/streamhub/TriggerPanel.cpp b/Client/streamhub/TriggerPanel.cpp index 53ff3db..bae5f74 100644 --- a/Client/streamhub/TriggerPanel.cpp +++ b/Client/streamhub/TriggerPanel.cpp @@ -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() - ? "