/** * @file App.cpp * @brief Application state machine implementation. */ #include "App.h" #include "SourcePanel.h" #include "PlotPanel.h" #include "TriggerPanel.h" #include "StatsPanel.h" #include "imgui.h" #include "implot.h" #include "Icons.h" #include #include #include #include #include namespace StreamHubClient { /* ── Layout icon helper ──────────────────────────────────────────────────── */ static void DrawLayoutIcon(ImDrawList* dl, ImVec2 tl, ImVec2 sz, int cols, int rows, bool selected) { ImU32 bg = selected ? IM_COL32(49,50,68,255) : IM_COL32(24,24,37,255); ImU32 cell = selected ? IM_COL32(137,180,250,200) : IM_COL32(88,91,112,160); ImU32 bord = selected ? IM_COL32(137,180,250,255) : IM_COL32(69,71,90,255); dl->AddRectFilled(tl, ImVec2(tl.x+sz.x, tl.y+sz.y), bg, 4.f); dl->AddRect(tl, ImVec2(tl.x+sz.x, tl.y+sz.y), bord, 4.f, 0, 1.f); float cw = sz.x / cols, rh = sz.y / rows; for (int c = 0; c < cols; c++) { for (int r = 0; r < rows; r++) { ImVec2 p0(tl.x + c*cw + 2.f, tl.y + r*rh + 2.f); ImVec2 p1(tl.x + (c+1)*cw - 2.f, tl.y + (r+1)*rh - 2.f); dl->AddRectFilled(p0, p1, cell, 2.f); } } } /* ── Signal ─────────────────────────────────────────────────────────────── */ /* ── App ─────────────────────────────────────────────────────────────────── */ App::App() { for (int i = 0; i < kMaxPlotSlots; i++) { plotPaused_[i] = false; liveFollow_[i] = true; activeSlot_[i] = -1; } } App::~App() { shutdown(); } void App::init(const std::string& host, uint16_t port) { snprintf(hostBuf_, sizeof(hostBuf_), "%s", host.c_str()); snprintf(portBuf_, sizeof(portBuf_), "%u", static_cast(port)); ws_.connect(host, port); /* Initial queries are sent on first connect transition in update() */ } void App::shutdown() { ws_.disconnect(); } /* ── Per-frame update ────────────────────────────────────────────────────── */ void App::update() { /* Send initial queries on first successful connect */ bool nowConnected = ws_.isConnected(); 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) { handleBinary(msg.data.data(), msg.data.size()); } else { std::string json(reinterpret_cast(msg.data.data()), msg.data.size()); handleJSON(json); } }); /* ── Full-screen dockspace ─────────────────────────────────────────── */ ImGuiViewport* vp = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(vp->WorkPos); ImGui::SetNextWindowSize(vp->WorkSize); ImGuiWindowFlags wf = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); ImGui::Begin("##main", nullptr, wf); ImGui::PopStyleVar(3); renderToolbar(); if (showHistBar_) { renderHistoryBar(); } if (showTrigBar_) { renderTriggerBar(); } /* Horizontal split: sidebar | plot grid */ float sidebarW = sidebarOpen_ ? 220.f : 0.f; float avail = ImGui::GetContentRegionAvail().x; float plotW = avail - sidebarW; if (sidebarOpen_) { ImGui::BeginChild("##sidebar", ImVec2(sidebarW, 0.f), false, ImGuiWindowFlags_NoMove); RenderSourcePanel(*this); ImGui::EndChild(); ImGui::SameLine(); } ImGui::BeginChild("##plots", ImVec2(plotW, 0.f), false); renderPlotGrid(); ImGui::EndChild(); ImGui::End(); /* Overlays / modals */ if (showStats_) { renderStatsModal(); } if (showAddSrc_) { renderAddSourceModal(); } } /* renderMenuBar() removed — all functionality merged into toolbar + sidebar */ /* ── Toolbar ─────────────────────────────────────────────────────────────── */ void App::renderToolbar() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f)); /* ── 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")) { ImGui::OpenPopup("##layout_pop"); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Layout"); } ImGui::SameLine(); if (ImGui::BeginPopup("##layout_pop")) { ImGui::TextDisabled("Select layout"); ImGui::Separator(); ImDrawList* ldl = ImGui::GetWindowDrawList(); for (int li = 0; li < static_cast(PlotLayout::kCount); li++) { bool sel = (static_cast(layout_) == li); ImVec2 p = ImGui::GetCursorScreenPos(); if (ImGui::InvisibleButton(PlotLayoutNames[li], kIconSz)) { setLayout(static_cast(li)); ImGui::CloseCurrentPopup(); } DrawLayoutIcon(ldl, p, kIconSz, kIconCols[li], kIconRows[li], sel); if (li < static_cast(PlotLayout::kCount) - 1) { ImGui::SameLine(0.f, 4.f); } } ImGui::EndPopup(); } /* ── Global pause ────────────────────────────────────────────────────── */ if (ImGui::Button(globalPaused_ ? ICON_FA_PLAY " Resume" : ICON_FA_PAUSE " Pause")) { globalPaused_ = !globalPaused_; for (int i = 0; i < numPlotSlots(); i++) { plotPaused_[i] = globalPaused_; if (!globalPaused_) { liveFollow_[i] = true; } } } ImGui::SameLine(); /* ── 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 "##curs")) { cursorsOn_ = !cursorsOn_; } if (cursorsOn_) { ImGui::PopStyleColor(); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Cursors A/B"); } } ImGui::SameLine(); /* ── 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(); /* ── 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(); /* ── 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)) { for (int wi = 0; wi < 5; wi++) { bool sel = std::fabs(windowSec_ - kWinPresets[wi]) < 1e-9; if (ImGui::Selectable(kWinLabels[wi], sel)) { windowSec_ = kWinPresets[wi]; } if (sel) { ImGui::SetItemDefaultFocus(); } } 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(); /* ── 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(); } /* ── Plot grid ───────────────────────────────────────────────────────────── */ void App::renderPlotGrid() { int cols, rows; PlotLayoutDims(layout_, cols, rows); /* Per-cell fraction arrays (reset when layout changes) */ static float rowFrac[8] = {1,1,1,1,1,1,1,1}; static float colFrac[8] = {1,1,1,1,1,1,1,1}; static PlotLayout prevLayout = PlotLayout::kCount; if (layout_ != prevLayout) { for (int i = 0; i < 8; i++) { rowFrac[i] = 1.f; colFrac[i] = 1.f; } prevLayout = layout_; } const float kSep = 5.f; /* separator strip width/height in px */ float avW = ImGui::GetContentRegionAvail().x; float avH = ImGui::GetContentRegionAvail().y; float cellTotalW = avW - kSep * (cols - 1); float cellTotalH = avH - kSep * (rows - 1); /* Normalise fractions */ float rowSum = 0.f; for (int r = 0; r < rows; r++) rowSum += rowFrac[r]; float colSum = 0.f; for (int c = 0; c < cols; c++) colSum += colFrac[c]; if (rowSum < 1e-6f) rowSum = 1.f; if (colSum < 1e-6f) colSum = 1.f; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.f, 0.f)); for (int r = 0; r < rows; r++) { float rh = std::max(30.f, cellTotalH * rowFrac[r] / rowSum); for (int c = 0; c < cols; c++) { float cw = std::max(60.f, cellTotalW * colFrac[c] / colSum); int plotIdx = r * cols + c; char cellId[32]; snprintf(cellId, sizeof(cellId), "##cell%d", plotIdx); ImGui::BeginChild(cellId, ImVec2(cw, rh), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); if (plotIdx < kMaxPlotSlots) { RenderPlotPanel(*this, plotIdx, plotPaused_[plotIdx]); } ImGui::EndChild(); /* ── Column separator ── */ if (c < cols - 1) { ImGui::SameLine(0.f, 0.f); char csId[32]; snprintf(csId, sizeof(csId), "##cs%d_%d", r, c); ImVec2 p0 = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton(csId, ImVec2(kSep, rh)); if (ImGui::IsItemActive()) { float dx = ImGui::GetIO().MouseDelta.x; colFrac[c] += dx * colSum / cellTotalW; colFrac[c+1] -= dx * colSum / cellTotalW; colFrac[c] = std::max(0.05f * colSum, colFrac[c]); colFrac[c+1] = std::max(0.05f * colSum, colFrac[c+1]); } if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); ImGui::GetWindowDrawList()->AddRectFilled( p0, ImVec2(p0.x + kSep, p0.y + rh), IM_COL32(49,50,68,255)); ImGui::SameLine(0.f, 0.f); } } /* end col loop */ /* ── Row separator ── */ if (r < rows - 1) { char rsId[32]; snprintf(rsId, sizeof(rsId), "##rs%d", r); /* Reposition X to left content edge before drawing full-width strip */ ImGui::SetCursorPosX(0.f); ImVec2 p0 = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton(rsId, ImVec2(avW, kSep)); if (ImGui::IsItemActive()) { float dy = ImGui::GetIO().MouseDelta.y; rowFrac[r] += dy * rowSum / cellTotalH; rowFrac[r+1] -= dy * rowSum / cellTotalH; rowFrac[r] = std::max(0.05f * rowSum, rowFrac[r]); rowFrac[r+1] = std::max(0.05f * rowSum, rowFrac[r+1]); } if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); ImGui::GetWindowDrawList()->AddRectFilled( p0, ImVec2(p0.x + avW, p0.y + kSep), IM_COL32(49,50,68,255)); } } /* end row loop */ ImGui::PopStyleVar(); } /* ── Stats modal ─────────────────────────────────────────────────────────── */ void App::renderStatsModal() { ImGui::SetNextWindowSize(ImVec2(700.f, 300.f), ImGuiCond_FirstUseEver); if (ImGui::Begin("Statistics", &showStats_)) { RenderStatsPanel(*this); } ImGui::End(); } /* ── Add-source modal ────────────────────────────────────────────────────── */ void App::renderAddSourceModal() { ImGui::SetNextWindowSize(ImVec2(360.f, 260.f), ImGuiCond_Always); ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Add Source", &showAddSrc_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { static char label[128]= ""; static char host[64] = "127.0.0.1"; static int port = 44500; static char mcast[64] = ""; static int dataPort = 0; ImGui::InputText("Label", label, sizeof(label)); ImGui::InputText("Host", host, sizeof(host)); ImGui::InputInt("Port", &port); ImGui::InputText("Multicast", mcast, sizeof(mcast)); ImGui::InputInt("Data Port", &dataPort); ImGui::Spacing(); if (ImGui::Button("Connect", ImVec2(100.f, 0.f))) { if (host[0] != '\0' && port > 0 && port < 65536) { char addr[96]; snprintf(addr, sizeof(addr), "%s:%d", host, port); ws_.sendText(BuildAddSource(label, addr, mcast, static_cast( (dataPort > 0 && dataPort < 65536) ? dataPort : 0))); showAddSrc_ = false; label[0] = '\0'; } } ImGui::SameLine(); if (ImGui::Button("Cancel", ImVec2(80.f, 0.f))) { showAddSrc_ = false; } } ImGui::End(); } /* ── Trigger bar ─────────────────────────────────────────────────────────── */ void App::renderTriggerBar() { RenderTriggerPanel(*this); } /* ── Layout ──────────────────────────────────────────────────────────────── */ int App::numPlotSlots() const { int cols, rows; PlotLayoutDims(layout_, cols, rows); return cols * rows; } void App::setLayout(PlotLayout l) { layout_ = l; } /* ── Source helpers ──────────────────────────────────────────────────────── */ int App::findSource(const std::string& id) const { for (int i = 0; i < static_cast(sources_.size()); i++) { if (sources_[i].id == id) { return i; } } return -1; } int App::findSignal(const Source& src, const std::string& name) const { for (int i = 0; i < static_cast(src.signals.size()); i++) { if (src.signals[i].meta.name == name) { return i; } } return -1; } /* ── WS message dispatch ─────────────────────────────────────────────────── */ void App::handleJSON(const std::string& json) { std::string type = ParseType(json); if (type == "sources") { onSources(json); } else if (type == "config") { onConfig(json); } else if (type == "stats") { onStats(json); } else if (type == "triggerState") { onTriggerState(json); } else if (type == "zoom") { onZoom(json); } else if (type == "historyZoom") { onHistoryZoom(json); } else if (type == "historyInfo") { onHistoryInfo(json); } else if (type == "maxPointsUpdated") { onMaxPointsUpdated(json); } /* pong: ignore */ } void App::handleBinary(const uint8_t* data, size_t len) { if (len == 0) { return; } /* Version 2: trigger capture frame */ if (data[0] == 2u) { CaptureFrame cf; if (ParseCaptureFrame(data, len, cf)) { capture_ = std::move(cf); hasCapture_ = true; trigger_.status = "triggered"; trigger_.trigTime = capture_.trigTime; trigger_.hasTrigTime = true; resetTrigZoom(); } return; } /* Version 1: live push frame. The hub pushes only samples that are new * since the previous tick (non-overlapping), so push verbatim. */ DataFrame frame; if (!ParseBinaryFrame(data, len, frame)) { return; } std::lock_guard lk(srcMutex_); int srcIdx = findSource(frame.sourceId); if (srcIdx < 0) { return; } Source& src = sources_[srcIdx]; for (const auto& fs : frame.signals) { int sigIdx = findSignal(src, fs.name); if (sigIdx < 0) { continue; } /* Always push: the ring is the "active" buffer; paused plots render * from their frozen view snapshot, so no data is ever dropped. */ size_t n = std::min(fs.t.size(), fs.v.size()); auto& buf = src.signals[sigIdx].buf; for (size_t i = 0; i < n; i++) { buf.push(fs.t[i], fs.v[i]); } } } /* ── JSON event handlers ─────────────────────────────────────────────────── */ void App::onSources(const std::string& json) { std::vector infos; ParseSources(json, infos); std::lock_guard lk(srcMutex_); for (const auto& info : infos) { int idx = findSource(info.id); if (idx < 0) { Source s; s.id = info.id; s.label = info.label; s.addr = info.addr; s.port = info.port; s.state = info.state; sources_.push_back(std::move(s)); } else { sources_[idx].state = info.state; sources_[idx].label = info.label; } } /* Remove sources that are no longer listed */ sources_.erase(std::remove_if(sources_.begin(), sources_.end(), [&infos](const Source& s) { for (const auto& i : infos) { if (i.id == s.id) { return false; } } return true; }), sources_.end()); } void App::onConfig(const std::string& json) { std::string sourceId; int publishMode = 0; std::vector metas; if (!ParseConfig(json, sourceId, publishMode, metas)) { return; } std::lock_guard lk(srcMutex_); int idx = findSource(sourceId); if (idx < 0) { return; } Source& src = sources_[idx]; src.configured = true; src.publishMode = publishMode; /* Colour palette — Catppuccin Mocha trace colours */ static const ImVec4 kPalette[] = { {0.537f,0.706f,0.980f,1.f}, /* blue */ {0.651f,0.890f,0.631f,1.f}, /* green */ {0.953f,0.545f,0.659f,1.f}, /* red */ {0.980f,0.702f,0.529f,1.f}, /* peach */ {0.796f,0.651f,0.969f,1.f}, /* mauve */ {0.580f,0.886f,0.835f,1.f}, /* teal */ {0.537f,0.863f,0.922f,1.f}, /* sky */ {0.706f,0.745f,0.996f,1.f}, /* lavender */ }; /* Merge: add new signals, keep existing ring buffers */ for (size_t m = 0; m < metas.size(); m++) { int si = findSignal(src, metas[m].name); if (si < 0) { Signal sig; sig.meta = metas[m]; sig.color = kPalette[src.signals.size() % 8]; src.signals.push_back(std::move(sig)); } else { src.signals[si].meta = metas[m]; } } } void App::onStats(const std::string& json) { std::vector> statsVec; ParseStats(json, statsVec); std::lock_guard lk(srcMutex_); for (const auto& kv : statsVec) { int idx = findSource(kv.first); if (idx >= 0) { sources_[idx].stats = kv.second; sources_[idx].state = kv.second.state; } } } void App::onTriggerState(const std::string& json) { TriggerStateMsg msg; if (!ParseTriggerState(json, msg)) { return; } trigger_.status = msg.state; trigger_.stopped = msg.stopped; if (msg.hasTrigTime) { trigger_.trigTime = msg.trigTime; trigger_.hasTrigTime = true; } /* Double-buffer semantics: the last recorded capture stays on display * (even while re-armed/collecting) and is only replaced when a new * capture frame has been fully received and parsed (handleBinary v2). */ if (msg.state == "idle") { trigger_.hasTrigTime = false; } } void App::onZoom(const std::string& json) { ZoomResponse resp; if (!ParseZoom(json, resp)) { return; } /* Route the reply to the plot that requested it */ for (int i = 0; i < kMaxPlotSlots; i++) { auto& zc = zoomCache_[i]; if (zc.pending && zc.reqId == resp.reqId) { /* 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; } } } void App::requestZoom(int plotIdx, double t0, double t1, const std::string& signalsCsv) { if (plotIdx < 0 || plotIdx >= kMaxPlotSlots) { return; } if (!ws_.isConnected() || signalsCsv.empty()) { return; } auto& zc = zoomCache_[plotIdx]; zc.reqId = nextZoomReqId_++; zc.reqT0 = t0; zc.reqT1 = t1; zc.pending = true; ws_.sendText(BuildZoom(zc.reqId, t0, t1, 2400, signalsCsv)); } std::string App::slotKey(const PlotAssignment& a) const { if (a.sourceIdx < 0 || a.sourceIdx >= static_cast(sources_.size())) { return std::string(); } const Source& src = sources_[a.sourceIdx]; if (a.signalIdx < 0 || a.signalIdx >= static_cast(src.signals.size())) { return std::string(); } return src.id + ":" + src.signals[a.signalIdx].meta.name; } void App::onMaxPointsUpdated(const std::string& json) { uint32_t mp = 0; ParseMaxPointsUpdated(json, mp); if (mp >= 2) { maxPoints_ = mp; std::lock_guard lk(srcMutex_); for (auto& src : sources_) { for (auto& sig : src.signals) { sig.buf.setCapacity(mp); } } } } /* ---- History ------------------------------------------------------------ */ void App::onHistoryZoom(const std::string& json) { ZoomResponse resp; if (!ParseZoom(json, resp)) { return; } /* Route the reply to the plot that requested it (via histZoomCache_) */ for (int i = 0; i < kMaxPlotSlots; i++) { auto& hc = histZoomCache_[i]; if (hc.pending && hc.reqId == resp.reqId) { /* 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; } } } void App::onHistoryInfo(const std::string& json) { ParseHistoryInfo(json, historyInfo_); } void App::requestHistoryZoom(int plotIdx, double t0, double t1, const std::string& signalsCsv) { if (plotIdx < 0 || plotIdx >= kMaxPlotSlots) { return; } if (!ws_.isConnected() || signalsCsv.empty()) { return; } auto& hc = histZoomCache_[plotIdx]; hc.reqId = nextZoomReqId_++; hc.reqT0 = t0; hc.reqT1 = t1; hc.pending = true; ws_.sendText(BuildHistoryZoom(hc.reqId, t0, t1, 2400, signalsCsv)); } } /* namespace StreamHubClient */