/** * @file App.h * @brief Top-level application state and frame dispatch. */ #pragma once #include "WSClient.h" #include "Protocol.h" #include "SignalBuffer.h" #include #include #include #include #include #include "imgui.h" namespace StreamHubClient { /*---------------------------------------------------------------------------*/ /* Domain types */ /*---------------------------------------------------------------------------*/ /** One signal as known to the client. */ struct Signal { SignalMeta meta; SignalBuffer buf{1000000}; ImVec4 color{0.4f, 0.8f, 1.0f, 1.0f}; float lineWidth = 1.5f; int marker = -1; /* ImPlotMarker (-1 = none) */ bool visible = true; }; /** One connected UDPStreamer source. */ struct Source { std::string id; std::string label; std::string addr; std::string state = "disconnected"; uint32_t port = 0; bool configured = false; int publishMode = 0; std::vector signals; SourceStats stats; }; /** Trigger configuration and live state (hub-side trigger semantics). */ struct TriggerState { /* Config (sent to the hub via setTrigger) */ std::string signalKey; /* full key "src:sig" or "src:sig[i]" */ int edge = 0; /* 0=rising 1=falling 2=both */ double threshold = 0.0; double windowSec = 0.1; /* 100 µs .. 10 s */ double prePercent = 20.0; bool single = false; /* false=normal true=single */ /* Live (driven by hub triggerState broadcasts) */ std::string status = "idle"; /* idle|armed|collecting|triggered */ bool stopped = false; bool hasTrigTime = false; double trigTime = 0.0; }; /** Per-signal vertical scale state (oscilloscope style). */ struct VScale { int mode = 0; /* 0=auto 1=range 2=manual */ double divValue = 1.0; /* units per division (manual mode) */ double offset = 0.0; /* raw value at center (manual mode) */ double screenPos = 0.0; /* position offset in divisions from center */ bool digitalInMixed = false; /* quantize this trace in mixed plot mode */ /* Resolved each frame (read-only): */ double resolvedDiv = 1.0; double resolvedOffset = 0.0; }; /** Assignment of one signal to a plot panel. */ struct PlotAssignment { int sourceIdx = -1; int signalIdx = -1; VScale vs; }; /*---------------------------------------------------------------------------*/ /* Plot layouts */ /*---------------------------------------------------------------------------*/ /* Layout set mirrors the web UI: cols×rows */ enum class PlotLayout { kLayout1x1 = 0, kLayout2x1, /* 2 side by side */ kLayout1x2, /* 2 stacked */ kLayout3x1, kLayout1x3, kLayout2x2, kLayout4x1, kLayout1x4, kCount }; static inline const char* const PlotLayoutNames[] = { "1×1", "2×1", "1×2", "3×1", "1×3", "2×2", "4×1", "1×4" }; static inline void PlotLayoutDims(PlotLayout l, int& cols, int& rows) { switch (l) { case PlotLayout::kLayout1x1: cols=1; rows=1; break; case PlotLayout::kLayout2x1: cols=2; rows=1; break; case PlotLayout::kLayout1x2: cols=1; rows=2; break; case PlotLayout::kLayout3x1: cols=3; rows=1; break; case PlotLayout::kLayout1x3: cols=1; rows=3; break; case PlotLayout::kLayout2x2: cols=2; rows=2; break; case PlotLayout::kLayout4x1: cols=4; rows=1; break; case PlotLayout::kLayout1x4: cols=1; rows=4; break; default: cols=1; rows=1; break; } } /*---------------------------------------------------------------------------*/ /* App */ /*---------------------------------------------------------------------------*/ class App { public: App(); ~App(); /** @brief Initialise and start the WebSocket connection. */ void init(const std::string& host, uint16_t port); /** * @brief Call once per ImGui frame. * Drains the WS receive queue, dispatches messages, renders all panels. */ void update(); /** @brief Disconnect and clean up. */ void shutdown(); /* ---- Accessors used by sub-panels ----------------------------------- */ std::vector& sources() { return sources_; } const std::vector& sources() const { return sources_; } TriggerState& trigger() { return trigger_; } WSClient& ws() { return ws_; } uint32_t maxPoints() const { return maxPoints_; } /** @brief Last trigger capture received from the hub (nullptr if none). */ const CaptureFrame* capture() const { return hasCapture_ ? &capture_ : nullptr; } void clearCapture() { hasCapture_ = false; } /** @brief Get the plot assignment vector for plot index i. */ std::vector& plotSlot(int i) { return plotSlots_[i]; } int numPlotSlots() const; PlotLayout plotLayout() const { return layout_; } void setLayout(PlotLayout l); /** @brief True if all plots are paused (global pause). */ bool globalPaused() const { return globalPaused_; } void setGlobalPaused(bool p) { globalPaused_ = p; } /** @brief Live-follow state per plot (true = auto-scroll with newest data). */ bool& liveFollow(int i) { return liveFollow_[i]; } /** @brief Which slot index is "active" (selected) in the given plot (-1 = none). */ int& activeSlot(int i) { return activeSlot_[i]; } /** @brief Time window width in seconds used by live scroll. */ double windowSec() const { return windowSec_; } void setWindowSec(double s){ if (s >= 1e-4 && s <= 3600.0) windowSec_ = s; } /** @brief Stored X range for non-live (zoom/pan) mode per plot. */ double plotXMin(int i) const { return plotXMin_[i]; } double plotXMax(int i) const { return plotXMax_[i]; } void setPlotX(int i, double mn, double mx) { if (mx > mn + 1e-9) { plotXMin_[i] = mn; plotXMax_[i] = mx; } } /** @brief Call when transitioning live→non-live to seed the stored X range. */ void initPlotX(int i, double tMax) { plotXMin_[i] = tMax - windowSec_; plotXMax_[i] = tMax; } /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */ int& plotVMode(int i) { return plotVMode_[i]; } /* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */ bool& cursorsOn() { return cursorsOn_; } double& cursorA() { return cursorA_; } double& cursorB() { return cursorB_; } /* ---- Paused view snapshot (per plot) ---------------------------------- * * Double-buffer scheme: the per-signal ring buffers stay "active" (always * written by the WS thread-drain); a paused plot freezes a "view" copy of * its slots' data at pause time and renders from it, so the picture does * not drain away while the rings keep rolling. */ struct PlotSnapshot { bool valid = false; std::vector keys; /* slot key "src:sig" */ std::vector> t, v; }; PlotSnapshot& plotSnap(int i) { return plotSnap_[i]; } /* ---- Zoom history (per plot) ----------------------------------------- */ std::vector>& zoomHist(int i) { return zoomHist_[i]; } /** @brief Push the current stored X range onto the history (capped). */ void pushZoomHist(int i) { auto& h = zoomHist_[i]; if (!h.empty() && h.back().first == plotXMin_[i] && h.back().second == plotXMax_[i]) { return; } if (h.size() >= 64) { h.erase(h.begin()); } h.emplace_back(plotXMin_[i], plotXMax_[i]); } /* ---- Hi-res WS zoom (per plot) ---------------------------------------- */ struct PlotZoomCache { bool valid = false; /* signals[] match [t0,t1] */ bool pending = false; /* request in flight */ uint32_t reqId = 0; double t0 = 0.0, t1 = 0.0; /* range of valid data */ double reqT0 = 0.0, reqT1 = 0.0; /* range of pending request */ std::vector signals; }; PlotZoomCache& zoomCache(int i) { return zoomCache_[i]; } /** @brief Send a hub zoom request for plot i over [t0,t1] (2400 pts). */ void requestZoom(int plotIdx, double t0, double t1, const std::string& signalsCsv); /** @brief Full key "src:sig" for an assignment (empty if invalid). */ std::string slotKey(const PlotAssignment& a) const; /* ---- History ------------------------------------------------------------ */ /** @brief History info received from the hub. */ const HistoryInfoMsg& historyInfo() const { return historyInfo_; } /** @brief Send a historyZoom request. */ void requestHistoryZoom(int plotIdx, double t0, double t1, const std::string& signalsCsv); /** @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; /** @brief Find signal index in source by name (-1 if not found). */ int findSignal(const Source& src, const std::string& name) const; private: /* ---- WS message dispatch ------------------------------------------- */ void handleJSON(const std::string& json); void handleBinary(const uint8_t* data, size_t len); void onSources(const std::string& json); void onConfig(const std::string& json); void onStats(const std::string& json); void onTriggerState(const std::string& json); void onZoom(const std::string& json); void onHistoryZoom(const std::string& json); void onHistoryInfo(const std::string& json); void onMaxPointsUpdated(const std::string& json); /* ---- Rendering ------------------------------------------------------ */ void renderToolbar(); void renderHistoryBar(); void renderTriggerBar(); void renderSidebar(); void renderPlotGrid(); void renderStatsModal(); void renderAddSourceModal(); /* ---- State ---------------------------------------------------------- */ WSClient ws_; std::vector sources_; mutable std::mutex srcMutex_; /* protects sources_ */ TriggerState trigger_; PlotLayout layout_ = PlotLayout::kLayout1x1; uint32_t maxPoints_ = 1000000u; bool globalPaused_ = false; /* Plot slots: one vector of assignments per plot panel */ static const int kMaxPlotSlots = 8; std::vector plotSlots_[kMaxPlotSlots]; bool plotPaused_[kMaxPlotSlots] = {}; bool liveFollow_[kMaxPlotSlots] = {}; /* true = live-scroll X axis */ int activeSlot_[kMaxPlotSlots]; /* which slot is "active" (-1 = none) */ double windowSec_ = 10.0; /* live scroll window width */ double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */ double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */ int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */ /* Cursors (global) */ bool cursorsOn_ = false; 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]; /* Zoom history + hi-res zoom cache */ std::vector> zoomHist_[kMaxPlotSlots]; PlotZoomCache zoomCache_[kMaxPlotSlots]; PlotZoomCache histZoomCache_[kMaxPlotSlots]; uint32_t nextZoomReqId_ = 1; /* 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) */ char hostBuf_[64] = "127.0.0.1"; char portBuf_[8] = "8090"; /* Track connect transition to send initial queries */ bool prevConnected_ = false; /* Last trigger capture (version=2 binary frame; render thread only) */ CaptureFrame capture_; bool hasCapture_ = false; }; } /* namespace StreamHubClient */