709 lines
26 KiB
C++
709 lines
26 KiB
C++
/**
|
|
* @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 <cstring>
|
|
#include <cstdio>
|
|
#include <cmath>
|
|
#include <chrono>
|
|
#include <algorithm>
|
|
|
|
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<unsigned>(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());
|
|
}
|
|
prevConnected_ = nowConnected;
|
|
|
|
/* 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<const char*>(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
|
|
| ImGuiWindowFlags_MenuBar;
|
|
|
|
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);
|
|
|
|
renderMenuBar();
|
|
renderToolbar();
|
|
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(); }
|
|
}
|
|
|
|
/* ── 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();
|
|
}
|
|
|
|
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
|
|
|
void App::renderToolbar() {
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f));
|
|
|
|
/* 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");
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::BeginPopup("##layout_pop")) {
|
|
ImGui::TextDisabled("Select layout");
|
|
ImGui::Separator();
|
|
ImDrawList* ldl = ImGui::GetWindowDrawList();
|
|
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();
|
|
}
|
|
DrawLayoutIcon(ldl, p, kIconSz,
|
|
kIconCols[li], kIconRows[li], sel);
|
|
if (li < static_cast<int>(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 (global: shared & synchronised across all plots) */
|
|
{
|
|
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 (cursorsOn_) { ImGui::PopStyleColor(); }
|
|
}
|
|
ImGui::SameLine();
|
|
|
|
/* Trigger bar toggle */
|
|
if (ImGui::Button(ICON_FA_BOLT " Trigger")) { showTrigBar_ = !showTrigBar_; }
|
|
ImGui::SameLine();
|
|
|
|
/* Stats */
|
|
if (ImGui::Button(ICON_FA_CHART_COLUMN " Stats")) { showStats_ = !showStats_; }
|
|
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) */
|
|
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();
|
|
}
|
|
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_));
|
|
}
|
|
}
|
|
|
|
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<uint16_t>(
|
|
(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<int>(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<int>(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 == "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;
|
|
}
|
|
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<std::mutex> 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<SourceInfo> infos;
|
|
ParseSources(json, infos);
|
|
|
|
std::lock_guard<std::mutex> 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<SignalMeta> metas;
|
|
|
|
if (!ParseConfig(json, sourceId, publishMode, metas)) { return; }
|
|
|
|
std::lock_guard<std::mutex> 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<std::pair<std::string, SourceStats>> statsVec;
|
|
ParseStats(json, statsVec);
|
|
|
|
std::lock_guard<std::mutex> 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) {
|
|
zc.signals = std::move(resp.signals);
|
|
zc.t0 = zc.reqT0;
|
|
zc.t1 = zc.reqT1;
|
|
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<int>(sources_.size())) {
|
|
return std::string();
|
|
}
|
|
const Source& src = sources_[a.sourceIdx];
|
|
if (a.signalIdx < 0 || a.signalIdx >= static_cast<int>(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<std::mutex> lk(srcMutex_);
|
|
for (auto& src : sources_) {
|
|
for (auto& sig : src.signals) {
|
|
sig.buf.setCapacity(mp);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} /* namespace StreamHubClient */
|