1356 lines
66 KiB
C++
1356 lines
66 KiB
C++
#include "capture_file_writer.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstdint>
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <iomanip>
|
|
#include <limits>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
constexpr std::size_t kLivePlotMaxColumns = 800;
|
|
constexpr std::size_t kLivePlotMidColumns = 4096;
|
|
constexpr std::size_t kLivePlotHighColumns = 16384;
|
|
constexpr std::size_t kLivePlotRawFrameLimit = 50000;
|
|
constexpr uint32_t kCsvSpoolMagic = 0x4C564353U; // "SVCL"
|
|
constexpr uint32_t kCsvSpoolVersion = 2U;
|
|
|
|
struct CsvSpoolFileHeader {
|
|
uint32_t magic = kCsvSpoolMagic;
|
|
uint32_t version = kCsvSpoolVersion;
|
|
uint32_t channel_count = 0;
|
|
uint32_t has_di1_trace = 0;
|
|
};
|
|
|
|
struct CsvSpoolPacketHeader {
|
|
uint64_t packet_index = 0;
|
|
uint64_t channel_count = 0;
|
|
uint64_t frame_count = 0;
|
|
uint64_t has_di1_trace = 0;
|
|
};
|
|
|
|
struct PlotPoint {
|
|
std::size_t sample_index = 0;
|
|
double value = 0.0;
|
|
};
|
|
|
|
struct Di1GroupedTraces {
|
|
std::vector<PlotPoint> ch1;
|
|
std::vector<PlotPoint> ch2;
|
|
std::vector<PlotPoint> rss;
|
|
};
|
|
|
|
std::size_t packet_frame_count(const CapturePacket& packet);
|
|
|
|
std::size_t packet_channel_count(const CapturePacket& packet) {
|
|
return (packet.channel_count <= 1U) ? 1U : 2U;
|
|
}
|
|
|
|
bool packet_has_ch2(const CapturePacket& packet) {
|
|
return packet_channel_count(packet) >= 2U;
|
|
}
|
|
|
|
bool packet_has_di1_trace(const CapturePacket& packet) {
|
|
return packet.has_di1_trace && !packet.di1.empty();
|
|
}
|
|
|
|
std::vector<double> build_rss_values(const CapturePacket& packet) {
|
|
const std::size_t frames = packet_frame_count(packet);
|
|
std::vector<double> rss;
|
|
if (!packet_has_ch2(packet) || (frames == 0U)) {
|
|
return rss;
|
|
}
|
|
|
|
rss.reserve(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
const double ch1 = packet.ch1[i];
|
|
const double ch2 = packet.ch2[i];
|
|
rss.push_back(std::sqrt((ch1 * ch1) + (ch2 * ch2)));
|
|
}
|
|
return rss;
|
|
}
|
|
|
|
std::size_t packet_frame_count(const CapturePacket& packet) {
|
|
std::size_t frames = packet_has_ch2(packet) ? std::min(packet.ch1.size(), packet.ch2.size()) : packet.ch1.size();
|
|
if (packet_has_di1_trace(packet)) {
|
|
frames = std::min(frames, packet.di1.size());
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
std::vector<PlotPoint> build_min_max_trace(const std::vector<double>& data, std::size_t max_columns) {
|
|
std::vector<PlotPoint> result;
|
|
if (data.empty()) {
|
|
return result;
|
|
}
|
|
|
|
const std::size_t bucket_size = std::max<std::size_t>(1, (data.size() + max_columns - 1) / max_columns);
|
|
result.reserve(max_columns * 2);
|
|
|
|
for (std::size_t begin = 0; begin < data.size(); begin += bucket_size) {
|
|
const std::size_t end = std::min(begin + bucket_size, data.size());
|
|
std::size_t min_index = begin;
|
|
std::size_t max_index = begin;
|
|
for (std::size_t i = begin + 1; i < end; ++i) {
|
|
if (data[i] < data[min_index]) {
|
|
min_index = i;
|
|
}
|
|
if (data[i] > data[max_index]) {
|
|
max_index = i;
|
|
}
|
|
}
|
|
|
|
if (min_index <= max_index) {
|
|
result.push_back({min_index, data[min_index]});
|
|
if (max_index != min_index) {
|
|
result.push_back({max_index, data[max_index]});
|
|
}
|
|
} else {
|
|
result.push_back({max_index, data[max_index]});
|
|
result.push_back({min_index, data[min_index]});
|
|
}
|
|
}
|
|
|
|
if (result.back().sample_index != (data.size() - 1)) {
|
|
result.push_back({data.size() - 1, data.back()});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
std::vector<PlotPoint> build_full_trace(const std::vector<double>& data) {
|
|
std::vector<PlotPoint> result;
|
|
result.reserve(data.size());
|
|
for (std::size_t i = 0; i < data.size(); ++i) {
|
|
result.push_back({i, data[i]});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void append_offset_trace(std::vector<PlotPoint>& dst,
|
|
const std::vector<PlotPoint>& src,
|
|
std::size_t sample_offset) {
|
|
dst.reserve(dst.size() + src.size());
|
|
for (const auto& point : src) {
|
|
dst.push_back({sample_offset + point.sample_index, point.value});
|
|
}
|
|
}
|
|
|
|
Di1GroupedTraces build_di1_grouped_traces(const std::vector<double>& ch1,
|
|
const std::vector<double>& ch2,
|
|
const std::vector<uint8_t>& di1,
|
|
bool has_ch2) {
|
|
Di1GroupedTraces traces;
|
|
const std::size_t frames = std::min(ch1.size(), di1.size());
|
|
if (frames == 0U) {
|
|
return traces;
|
|
}
|
|
|
|
traces.ch1.reserve(frames / 2U + 1U);
|
|
if (has_ch2) {
|
|
traces.ch2.reserve(frames / 2U + 1U);
|
|
traces.rss.reserve(frames / 2U + 1U);
|
|
}
|
|
|
|
std::size_t begin = 0U;
|
|
while (begin < frames) {
|
|
const uint8_t level = di1[begin];
|
|
std::size_t end = begin + 1U;
|
|
while ((end < frames) && (di1[end] == level)) {
|
|
++end;
|
|
}
|
|
|
|
double sum1 = 0.0;
|
|
double sum2 = 0.0;
|
|
for (std::size_t i = begin; i < end; ++i) {
|
|
sum1 += ch1[i];
|
|
if (has_ch2 && (i < ch2.size())) {
|
|
sum2 += ch2[i];
|
|
}
|
|
}
|
|
|
|
const double count = static_cast<double>(end - begin);
|
|
const std::size_t mid = begin + ((end - begin - 1U) / 2U);
|
|
const double avg1 = sum1 / count;
|
|
traces.ch1.push_back({mid, avg1});
|
|
|
|
if (has_ch2) {
|
|
const double avg2 = sum2 / count;
|
|
traces.ch2.push_back({mid, avg2});
|
|
traces.rss.push_back({mid, std::sqrt((avg1 * avg1) + (avg2 * avg2))});
|
|
}
|
|
|
|
begin = end;
|
|
}
|
|
|
|
return traces;
|
|
}
|
|
|
|
std::vector<PlotPoint> build_digital_step_trace(const std::vector<uint8_t>& data) {
|
|
std::vector<PlotPoint> result;
|
|
if (data.empty()) {
|
|
return result;
|
|
}
|
|
|
|
result.reserve(std::min<std::size_t>(data.size() * 2U, 16384U));
|
|
uint8_t previous = data.front();
|
|
result.push_back({0U, static_cast<double>(previous)});
|
|
|
|
for (std::size_t i = 1; i < data.size(); ++i) {
|
|
const uint8_t current = data[i];
|
|
if (current != previous) {
|
|
result.push_back({i - 1U, static_cast<double>(previous)});
|
|
result.push_back({i, static_cast<double>(current)});
|
|
previous = current;
|
|
}
|
|
}
|
|
|
|
if (result.back().sample_index != (data.size() - 1U)) {
|
|
result.push_back({data.size() - 1U, static_cast<double>(data.back())});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
std::string polyline_points(const std::vector<PlotPoint>& trace,
|
|
std::size_t packet_start_index,
|
|
std::size_t total_frames,
|
|
double y_min,
|
|
double y_max,
|
|
double left,
|
|
double top,
|
|
double width,
|
|
double height) {
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(3);
|
|
const double y_span = std::max(1e-12, y_max - y_min);
|
|
const double x_span = (total_frames > 1U) ? static_cast<double>(total_frames - 1U) : 1.0;
|
|
|
|
for (const auto& point : trace) {
|
|
const double global_index = static_cast<double>(packet_start_index + point.sample_index);
|
|
const double x = left + (global_index / x_span) * width;
|
|
const double y = top + height - ((point.value - y_min) / y_span) * height;
|
|
out << x << "," << y << " ";
|
|
}
|
|
|
|
return out.str();
|
|
}
|
|
|
|
std::string digital_polyline_points(const std::vector<PlotPoint>& trace,
|
|
std::size_t packet_start_index,
|
|
std::size_t total_frames,
|
|
double left,
|
|
double top,
|
|
double width,
|
|
double height) {
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(3);
|
|
const double x_span = (total_frames > 1U) ? static_cast<double>(total_frames - 1U) : 1.0;
|
|
|
|
for (const auto& point : trace) {
|
|
const double global_index = static_cast<double>(packet_start_index + point.sample_index);
|
|
const double x = left + (global_index / x_span) * width;
|
|
const double y = top + height - ((0.15 + 0.7 * point.value) * height);
|
|
out << x << "," << y << " ";
|
|
}
|
|
|
|
return out.str();
|
|
}
|
|
|
|
std::string json_points(const std::vector<PlotPoint>& trace, double frame_freq_hz) {
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(9);
|
|
out << "[";
|
|
bool first = true;
|
|
for (const auto& point : trace) {
|
|
if (!first) {
|
|
out << ",";
|
|
}
|
|
first = false;
|
|
out << "[" << (static_cast<double>(point.sample_index) / frame_freq_hz) << "," << point.value << "]";
|
|
}
|
|
out << "]";
|
|
return out.str();
|
|
}
|
|
|
|
std::string json_packet_markers(const std::vector<std::pair<std::size_t, std::size_t>>& markers,
|
|
double frame_freq_hz) {
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(9);
|
|
out << "[";
|
|
bool first = true;
|
|
for (const auto& marker : markers) {
|
|
if (!first) {
|
|
out << ",";
|
|
}
|
|
first = false;
|
|
out << "{\"t\":" << (static_cast<double>(marker.first) / frame_freq_hz)
|
|
<< ",\"label\":\"P" << marker.second << "\"}";
|
|
}
|
|
out << "]";
|
|
return out.str();
|
|
}
|
|
|
|
[[noreturn]] void fail_write(const std::string& message);
|
|
|
|
std::string replace_extension(const std::string& path, const std::string& new_extension) {
|
|
const std::size_t slash_pos = path.find_last_of("/\\");
|
|
const std::size_t dot_pos = path.find_last_of('.');
|
|
if ((dot_pos == std::string::npos) || ((slash_pos != std::string::npos) && (dot_pos < slash_pos))) {
|
|
return path + new_extension;
|
|
}
|
|
return path.substr(0, dot_pos) + new_extension;
|
|
}
|
|
|
|
std::string path_to_script_url(std::string path) {
|
|
for (char& ch : path) {
|
|
if (ch == '\\') {
|
|
ch = '/';
|
|
}
|
|
}
|
|
|
|
std::string encoded;
|
|
encoded.reserve(path.size() + 8);
|
|
for (char ch : path) {
|
|
switch (ch) {
|
|
case ' ':
|
|
encoded += "%20";
|
|
break;
|
|
case '#':
|
|
encoded += "%23";
|
|
break;
|
|
case '%':
|
|
encoded += "%25";
|
|
break;
|
|
case '?':
|
|
encoded += "%3F";
|
|
break;
|
|
default:
|
|
encoded.push_back(ch);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ((encoded.size() >= 2U) && std::isalpha(static_cast<unsigned char>(encoded[0])) && (encoded[1] == ':')) {
|
|
return "file:///" + encoded;
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
std::string csv_spool_path(const std::string& csv_path) {
|
|
return replace_extension(csv_path, ".capture_spool.bin");
|
|
}
|
|
|
|
void write_text_file(const std::string& path, const std::string& text) {
|
|
std::ofstream file(path, std::ios::binary);
|
|
if (!file) {
|
|
fail_write("Cannot open file for writing: " + path);
|
|
}
|
|
file << text;
|
|
}
|
|
|
|
void write_live_data_script(const std::string& path, const std::string& data_json) {
|
|
write_text_file(path, "window.e502LiveData = " + data_json + ";\n");
|
|
}
|
|
|
|
void write_live_html_document(const std::string& path, const std::string& data_script_url) {
|
|
std::ofstream html(path, std::ios::binary);
|
|
if (!html) {
|
|
fail_write("Cannot open live HTML for writing: " + path);
|
|
}
|
|
|
|
html << "<!DOCTYPE html>\n"
|
|
<< "<html lang=\"en\">\n"
|
|
<< "<head>\n"
|
|
<< " <meta charset=\"utf-8\"/>\n"
|
|
<< " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
|
|
<< " <title>E-502 Live Plot</title>\n"
|
|
<< " <style>\n"
|
|
<< " :root { color-scheme: light; --bg:#f4f7fb; --panel:#ffffff; --ink:#1f2d3d; --muted:#607080; --grid:#d9e2ec; --blue:#005bbb; --red:#d62828; --green:#1b8f3a; --orange:#ee8a12; }\n"
|
|
<< " body { margin:0; font-family:\"Segoe UI\", Arial, sans-serif; background:linear-gradient(180deg,#eef3f8 0%,#f8fbfd 100%); color:var(--ink); }\n"
|
|
<< " .wrap { max-width:1200px; margin:0 auto; padding:24px; }\n"
|
|
<< " .panel { background:var(--panel); border:1px solid #dbe4ed; border-radius:16px; box-shadow:0 10px 30px rgba(23,43,77,0.08); overflow:hidden; }\n"
|
|
<< " .head { padding:18px 22px; border-bottom:1px solid #e6edf4; display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }\n"
|
|
<< " .title { font-size:22px; font-weight:600; }\n"
|
|
<< " .meta { color:var(--muted); font-size:14px; display:flex; gap:18px; flex-wrap:wrap; }\n"
|
|
<< " .status { padding:14px 22px 0 22px; color:var(--muted); font-size:14px; }\n"
|
|
<< " .controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:12px 22px 0 22px; color:var(--muted); font-size:13px; }\n"
|
|
<< " .controls button { border:1px solid #cfd8e3; background:#ffffff; color:#203040; border-radius:10px; padding:7px 12px; font:inherit; cursor:pointer; }\n"
|
|
<< " .controls button:hover { background:#f5f8fb; }\n"
|
|
<< " .controls label { display:flex; align-items:center; gap:6px; }\n"
|
|
<< " .controls input { accent-color:#005bbb; }\n"
|
|
<< " .hint { color:#7a8794; }\n"
|
|
<< " canvas { display:block; width:100%; height:620px; background:#fbfdff; }\n"
|
|
<< " .legend { display:flex; gap:22px; padding:10px 22px 20px 22px; color:var(--muted); font-size:14px; }\n"
|
|
<< " .sw { display:inline-block; width:28px; height:3px; border-radius:2px; margin-right:8px; vertical-align:middle; }\n"
|
|
<< " </style>\n"
|
|
<< "</head>\n"
|
|
<< "<body>\n"
|
|
<< " <div class=\"wrap\">\n"
|
|
<< " <div class=\"panel\">\n"
|
|
<< " <div class=\"head\">\n"
|
|
<< " <div>\n"
|
|
<< " <div class=\"title\">E-502 Live Packet Plot</div>\n"
|
|
<< " <div class=\"meta\">\n"
|
|
<< " <span id=\"packetInfo\">Waiting for packets...</span>\n"
|
|
<< " <span id=\"timingInfo\"></span>\n"
|
|
<< " <span id=\"packetRateInfo\"></span>\n"
|
|
<< " <span id=\"zeroInfo\"></span>\n"
|
|
<< " <span id=\"zoomInfo\"></span>\n"
|
|
<< " </div>\n"
|
|
<< " </div>\n"
|
|
<< " <div class=\"meta\">\n"
|
|
<< " <span id=\"updateInfo\">Auto-refresh every 500 ms</span>\n"
|
|
<< " </div>\n"
|
|
<< " </div>\n"
|
|
<< " <div class=\"status\" id=\"statusText\">Open this page once and leave it open. It refreshes its data script to pick up a rolling continuous packet window.</div>\n"
|
|
<< " <div class=\"controls\">\n"
|
|
<< " <button type=\"button\" id=\"zoomXIn\">X+</button>\n"
|
|
<< " <button type=\"button\" id=\"zoomXOut\">X-</button>\n"
|
|
<< " <button type=\"button\" id=\"panLeft\">Left</button>\n"
|
|
<< " <button type=\"button\" id=\"panRight\">Right</button>\n"
|
|
<< " <button type=\"button\" id=\"zoomYIn\">Y+</button>\n"
|
|
<< " <button type=\"button\" id=\"zoomYOut\">Y-</button>\n"
|
|
<< " <button type=\"button\" id=\"resetZoom\">Reset</button>\n"
|
|
<< " <label><input type=\"checkbox\" id=\"autoZoom\" checked/>Auto view</label>\n"
|
|
<< " <span class=\"hint\">Wheel: X zoom, Shift+wheel: Y zoom, Left/Right buttons or arrow keys: pan X, double-click: reset. The live view shows a rolling continuous packet window; CSV stores all samples.</span>\n"
|
|
<< " </div>\n"
|
|
<< " <canvas id=\"plot\" width=\"1200\" height=\"620\"></canvas>\n"
|
|
<< " <div class=\"legend\">\n"
|
|
<< " <span id=\"legendCh1\"><span class=\"sw\" style=\"background:#005bbb\"></span>CH1</span>\n"
|
|
<< " <span id=\"legendCh2\"><span class=\"sw\" style=\"background:#d62828\"></span>CH2</span>\n"
|
|
<< " <span id=\"legendRss\"><span class=\"sw\" style=\"background:#ee8a12\"></span>sqrt(CH1^2 + CH2^2)</span>\n"
|
|
<< " <span id=\"legendDi1\"><span class=\"sw\" style=\"background:#1b8f3a\"></span>DI1</span>\n"
|
|
<< " </div>\n"
|
|
<< " </div>\n"
|
|
<< " </div>\n"
|
|
<< " <script>\n"
|
|
<< " const dataScriptUrl = '" << data_script_url << "';\n"
|
|
<< " const canvas = document.getElementById('plot');\n"
|
|
<< " const ctx = canvas.getContext('2d');\n"
|
|
<< " const statusText = document.getElementById('statusText');\n"
|
|
<< " const packetInfo = document.getElementById('packetInfo');\n"
|
|
<< " const timingInfo = document.getElementById('timingInfo');\n"
|
|
<< " const zeroInfo = document.getElementById('zeroInfo');\n"
|
|
<< " const zoomInfo = document.getElementById('zoomInfo');\n"
|
|
<< " const packetRateInfo = document.getElementById('packetRateInfo');\n"
|
|
<< " const updateInfo = document.getElementById('updateInfo');\n"
|
|
<< " const legendCh2 = document.getElementById('legendCh2');\n"
|
|
<< " const legendRss = document.getElementById('legendRss');\n"
|
|
<< " const legendDi1 = document.getElementById('legendDi1');\n"
|
|
<< " const zoomXIn = document.getElementById('zoomXIn');\n"
|
|
<< " const zoomXOut = document.getElementById('zoomXOut');\n"
|
|
<< " const panLeft = document.getElementById('panLeft');\n"
|
|
<< " const panRight = document.getElementById('panRight');\n"
|
|
<< " const zoomYIn = document.getElementById('zoomYIn');\n"
|
|
<< " const zoomYOut = document.getElementById('zoomYOut');\n"
|
|
<< " const resetZoom = document.getElementById('resetZoom');\n"
|
|
<< " const autoZoom = document.getElementById('autoZoom');\n"
|
|
<< " const viewStorageKey = 'e502_live_plot_view_v1';\n"
|
|
<< " let latestPacket = null;\n"
|
|
<< " let latestAutoBounds = null;\n"
|
|
<< " function defaultViewState() {\n"
|
|
<< " return { auto: true, xMin: null, xMax: null, yMin: null, yMax: null };\n"
|
|
<< " }\n"
|
|
<< " function loadViewState() {\n"
|
|
<< " try {\n"
|
|
<< " const raw = window.localStorage.getItem(viewStorageKey);\n"
|
|
<< " if (!raw) return defaultViewState();\n"
|
|
<< " const parsed = JSON.parse(raw);\n"
|
|
<< " return {\n"
|
|
<< " auto: parsed.auto !== false,\n"
|
|
<< " xMin: Number.isFinite(parsed.xMin) ? parsed.xMin : null,\n"
|
|
<< " xMax: Number.isFinite(parsed.xMax) ? parsed.xMax : null,\n"
|
|
<< " yMin: Number.isFinite(parsed.yMin) ? parsed.yMin : null,\n"
|
|
<< " yMax: Number.isFinite(parsed.yMax) ? parsed.yMax : null,\n"
|
|
<< " };\n"
|
|
<< " } catch (err) {\n"
|
|
<< " return defaultViewState();\n"
|
|
<< " }\n"
|
|
<< " }\n"
|
|
<< " let viewState = loadViewState();\n"
|
|
<< " function saveViewState() {\n"
|
|
<< " try { window.localStorage.setItem(viewStorageKey, JSON.stringify(viewState)); } catch (err) {}\n"
|
|
<< " }\n"
|
|
<< " function clampNumber(value, min, max, fallback) {\n"
|
|
<< " if (!Number.isFinite(value)) return fallback;\n"
|
|
<< " return Math.min(max, Math.max(min, value));\n"
|
|
<< " }\n"
|
|
<< " function currentViewBounds(bounds) {\n"
|
|
<< " if (!bounds || viewState.auto) {\n"
|
|
<< " return { xMin: 0, xMax: bounds ? bounds.maxT : 1, yMin: bounds ? bounds.minY : -1, yMax: bounds ? bounds.maxY : 1 };\n"
|
|
<< " }\n"
|
|
<< " const totalX = Math.max(1e-9, bounds.maxT);\n"
|
|
<< " const xMin = clampNumber(viewState.xMin, 0, totalX, 0);\n"
|
|
<< " const xMax = clampNumber(viewState.xMax, xMin + 1e-9, totalX, totalX);\n"
|
|
<< " const yPad = Math.max(0.05, (bounds.maxY - bounds.minY) * 4.0);\n"
|
|
<< " const yLimitMin = bounds.minY - yPad;\n"
|
|
<< " const yLimitMax = bounds.maxY + yPad;\n"
|
|
<< " const yMin = clampNumber(viewState.yMin, yLimitMin, yLimitMax - 1e-9, bounds.minY);\n"
|
|
<< " const yMax = clampNumber(viewState.yMax, yMin + 1e-9, yLimitMax, bounds.maxY);\n"
|
|
<< " return { xMin, xMax, yMin, yMax };\n"
|
|
<< " }\n"
|
|
<< " function zoomRange(min, max, factor, limitMin, limitMax) {\n"
|
|
<< " const totalSpan = Math.max(1e-9, limitMax - limitMin);\n"
|
|
<< " const minSpan = totalSpan / 1000.0;\n"
|
|
<< " let span = (max - min) * factor;\n"
|
|
<< " span = Math.max(minSpan, Math.min(totalSpan, span));\n"
|
|
<< " const center = (min + max) / 2.0;\n"
|
|
<< " let newMin = center - span / 2.0;\n"
|
|
<< " let newMax = center + span / 2.0;\n"
|
|
<< " if (newMin < limitMin) {\n"
|
|
<< " newMax += (limitMin - newMin);\n"
|
|
<< " newMin = limitMin;\n"
|
|
<< " }\n"
|
|
<< " if (newMax > limitMax) {\n"
|
|
<< " newMin -= (newMax - limitMax);\n"
|
|
<< " newMax = limitMax;\n"
|
|
<< " }\n"
|
|
<< " newMin = Math.max(limitMin, newMin);\n"
|
|
<< " newMax = Math.min(limitMax, newMax);\n"
|
|
<< " return { min: newMin, max: newMax };\n"
|
|
<< " }\n"
|
|
<< " function refreshAutoCheckbox() {\n"
|
|
<< " autoZoom.checked = !!viewState.auto;\n"
|
|
<< " }\n"
|
|
<< " function applyZoomX(factor) {\n"
|
|
<< " if (!latestAutoBounds || !latestPacket) return;\n"
|
|
<< " const current = currentViewBounds(latestAutoBounds);\n"
|
|
<< " const range = zoomRange(current.xMin, current.xMax, factor, 0, Math.max(1e-9, latestAutoBounds.maxT));\n"
|
|
<< " viewState = { auto: false, xMin: range.min, xMax: range.max, yMin: current.yMin, yMax: current.yMax };\n"
|
|
<< " saveViewState();\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " renderPacket(latestPacket);\n"
|
|
<< " }\n"
|
|
<< " function applyZoomY(factor) {\n"
|
|
<< " if (!latestAutoBounds || !latestPacket) return;\n"
|
|
<< " const current = currentViewBounds(latestAutoBounds);\n"
|
|
<< " const yPad = Math.max(0.05, (latestAutoBounds.maxY - latestAutoBounds.minY) * 4.0);\n"
|
|
<< " const range = zoomRange(current.yMin, current.yMax, factor, latestAutoBounds.minY - yPad, latestAutoBounds.maxY + yPad);\n"
|
|
<< " viewState = { auto: false, xMin: current.xMin, xMax: current.xMax, yMin: range.min, yMax: range.max };\n"
|
|
<< " saveViewState();\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " renderPacket(latestPacket);\n"
|
|
<< " }\n"
|
|
<< " function applyPanX(direction) {\n"
|
|
<< " if (!latestAutoBounds || !latestPacket) return;\n"
|
|
<< " const current = currentViewBounds(latestAutoBounds);\n"
|
|
<< " const totalX = Math.max(1e-9, latestAutoBounds.maxT);\n"
|
|
<< " const span = Math.max(1e-9, current.xMax - current.xMin);\n"
|
|
<< " if (span >= totalX) return;\n"
|
|
<< " const shift = span * 0.25 * direction;\n"
|
|
<< " let newMin = current.xMin + shift;\n"
|
|
<< " let newMax = current.xMax + shift;\n"
|
|
<< " if (newMin < 0) {\n"
|
|
<< " newMax -= newMin;\n"
|
|
<< " newMin = 0;\n"
|
|
<< " }\n"
|
|
<< " if (newMax > totalX) {\n"
|
|
<< " newMin -= (newMax - totalX);\n"
|
|
<< " newMax = totalX;\n"
|
|
<< " }\n"
|
|
<< " newMin = Math.max(0, newMin);\n"
|
|
<< " newMax = Math.min(totalX, newMax);\n"
|
|
<< " viewState = { auto: false, xMin: newMin, xMax: newMax, yMin: current.yMin, yMax: current.yMax };\n"
|
|
<< " saveViewState();\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " renderPacket(latestPacket);\n"
|
|
<< " }\n"
|
|
<< " function resetView() {\n"
|
|
<< " viewState = defaultViewState();\n"
|
|
<< " saveViewState();\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " if (latestPacket) renderPacket(latestPacket);\n"
|
|
<< " }\n"
|
|
<< " function drawAxes(minY, maxY, xMin, xMax) {\n"
|
|
<< " const left = 78, top = 24, width = canvas.width - 112, height = canvas.height - 92;\n"
|
|
<< " ctx.clearRect(0, 0, canvas.width, canvas.height);\n"
|
|
<< " ctx.fillStyle = '#fbfdff'; ctx.fillRect(0, 0, canvas.width, canvas.height);\n"
|
|
<< " ctx.fillStyle = '#ffffff'; ctx.fillRect(left, top, width, height);\n"
|
|
<< " ctx.strokeStyle = '#dbe4ed'; ctx.lineWidth = 1; ctx.strokeRect(left, top, width, height);\n"
|
|
<< " ctx.strokeStyle = '#edf2f7';\n"
|
|
<< " for (let i = 0; i <= 10; i += 1) {\n"
|
|
<< " const x = left + width * i / 10;\n"
|
|
<< " const y = top + height * i / 10;\n"
|
|
<< " ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + height); ctx.stroke();\n"
|
|
<< " ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(left + width, y); ctx.stroke();\n"
|
|
<< " }\n"
|
|
<< " ctx.fillStyle = '#607080'; ctx.font = '12px Segoe UI';\n"
|
|
<< " for (let i = 0; i <= 10; i += 1) {\n"
|
|
<< " const x = left + width * i / 10;\n"
|
|
<< " const t = xMin + (xMax - xMin) * i / 10;\n"
|
|
<< " ctx.fillText(t.toFixed(6), x - 18, top + height + 22);\n"
|
|
<< " const y = top + height - height * i / 10;\n"
|
|
<< " const v = minY + (maxY - minY) * i / 10;\n"
|
|
<< " ctx.fillText(v.toFixed(3), 8, y + 4);\n"
|
|
<< " }\n"
|
|
<< " return { left, top, width, height };\n"
|
|
<< " }\n"
|
|
<< " function drawTrace(points, color, box, minY, maxY, xMin, xMax) {\n"
|
|
<< " if (!points || points.length === 0) return;\n"
|
|
<< " const spanY = Math.max(1e-9, maxY - minY);\n"
|
|
<< " const spanT = Math.max(1e-9, xMax - xMin);\n"
|
|
<< " ctx.beginPath();\n"
|
|
<< " points.forEach((p, i) => {\n"
|
|
<< " const x = box.left + ((p[0] - xMin) / spanT) * box.width;\n"
|
|
<< " const y = box.top + box.height - ((p[1] - minY) / spanY) * box.height;\n"
|
|
<< " if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);\n"
|
|
<< " });\n"
|
|
<< " ctx.strokeStyle = color; ctx.lineWidth = 1.25; ctx.stroke();\n"
|
|
<< " }\n"
|
|
<< " function drawDigitalTrace(points, box, xMin, xMax) {\n"
|
|
<< " if (!points || points.length === 0) return;\n"
|
|
<< " const spanT = Math.max(1e-9, xMax - xMin);\n"
|
|
<< " const band = { left: box.left, top: box.top + 10, width: box.width, height: 44 };\n"
|
|
<< " ctx.fillStyle = 'rgba(27,143,58,0.06)'; ctx.fillRect(band.left, band.top, band.width, band.height);\n"
|
|
<< " ctx.strokeStyle = 'rgba(27,143,58,0.20)'; ctx.strokeRect(band.left, band.top, band.width, band.height);\n"
|
|
<< " ctx.fillStyle = '#1b8f3a'; ctx.font = '12px Segoe UI'; ctx.fillText('DI1', band.left + 6, band.top + 14);\n"
|
|
<< " ctx.beginPath();\n"
|
|
<< " points.forEach((p, i) => {\n"
|
|
<< " const x = band.left + ((p[0] - xMin) / spanT) * band.width;\n"
|
|
<< " const y = band.top + band.height - ((0.15 + 0.7 * p[1]) * band.height);\n"
|
|
<< " if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);\n"
|
|
<< " });\n"
|
|
<< " ctx.strokeStyle = '#1b8f3a'; ctx.lineWidth = 1.4; ctx.stroke();\n"
|
|
<< " }\n"
|
|
<< " function drawPacketMarkers(markers, box, xMin, xMax) {\n"
|
|
<< " if (!Array.isArray(markers) || markers.length === 0) return;\n"
|
|
<< " const spanT = Math.max(1e-9, xMax - xMin);\n"
|
|
<< " ctx.save();\n"
|
|
<< " ctx.setLineDash([4, 4]);\n"
|
|
<< " ctx.strokeStyle = '#9db0c2';\n"
|
|
<< " ctx.lineWidth = 1.0;\n"
|
|
<< " ctx.fillStyle = '#5c6f82';\n"
|
|
<< " ctx.font = '11px Segoe UI';\n"
|
|
<< " markers.forEach((m) => {\n"
|
|
<< " if (!m || !Number.isFinite(m.t) || (m.t < xMin) || (m.t > xMax)) return;\n"
|
|
<< " const x = box.left + ((m.t - xMin) / spanT) * box.width;\n"
|
|
<< " ctx.beginPath(); ctx.moveTo(x, box.top); ctx.lineTo(x, box.top + box.height); ctx.stroke();\n"
|
|
<< " if (m.label) ctx.fillText(m.label, x + 4, box.top + 16);\n"
|
|
<< " });\n"
|
|
<< " ctx.restore();\n"
|
|
<< " }\n"
|
|
<< " function pickTrace(base, mid, high, maxT, view) {\n"
|
|
<< " if (!Array.isArray(base) || base.length === 0) return [];\n"
|
|
<< " const totalT = Math.max(1e-9, maxT || 0);\n"
|
|
<< " const spanT = Math.max(1e-9, view.xMax - view.xMin);\n"
|
|
<< " const ratio = spanT / totalT;\n"
|
|
<< " if (ratio <= 0.10 && Array.isArray(high) && high.length > 0) return high;\n"
|
|
<< " if (ratio <= 0.35 && Array.isArray(mid) && mid.length > 0) return mid;\n"
|
|
<< " return base;\n"
|
|
<< " }\n"
|
|
<< " function renderWaiting(message) {\n"
|
|
<< " packetInfo.textContent = 'Waiting for packets...';\n"
|
|
<< " timingInfo.textContent = '';\n"
|
|
<< " zeroInfo.textContent = '';\n"
|
|
<< " zoomInfo.textContent = 'View: auto';\n"
|
|
<< " packetRateInfo.textContent = '';\n"
|
|
<< " legendCh2.style.display = '';\n"
|
|
<< " legendRss.style.display = 'none';\n"
|
|
<< " legendDi1.style.display = 'none';\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " updateInfo.textContent = 'Polling every 500 ms';\n"
|
|
<< " statusText.textContent = message;\n"
|
|
<< " ctx.clearRect(0, 0, canvas.width, canvas.height);\n"
|
|
<< " ctx.fillStyle = '#fbfdff'; ctx.fillRect(0, 0, canvas.width, canvas.height);\n"
|
|
<< " ctx.fillStyle = '#607080'; ctx.font = '20px Segoe UI'; ctx.fillText('Waiting for first packet...', 32, 80);\n"
|
|
<< " }\n"
|
|
<< " function renderPacket(data) {\n"
|
|
<< " latestPacket = data;\n"
|
|
<< " const ch1 = data.ch1 || [];\n"
|
|
<< " const ch1Mid = data.ch1Mid || [];\n"
|
|
<< " const ch1High = data.ch1High || [];\n"
|
|
<< " const ch2 = data.ch2 || [];\n"
|
|
<< " const ch2Mid = data.ch2Mid || [];\n"
|
|
<< " const ch2High = data.ch2High || [];\n"
|
|
<< " const rss = data.rss || [];\n"
|
|
<< " const rssMid = data.rssMid || [];\n"
|
|
<< " const rssHigh = data.rssHigh || [];\n"
|
|
<< " const ch1Grouped = data.ch1Grouped || [];\n"
|
|
<< " const ch2Grouped = data.ch2Grouped || [];\n"
|
|
<< " const rssGrouped = data.rssGrouped || [];\n"
|
|
<< " const di1 = data.di1 || [];\n"
|
|
<< " const packetMarkers = data.packetMarkers || [];\n"
|
|
<< " const di1Grouped = !!data.di1Grouped;\n"
|
|
<< " const hasCh2 = (data.channelCount || 2) > 1 && ch2.length > 0;\n"
|
|
<< " const hasRss = hasCh2 && rss.length > 0;\n"
|
|
<< " const hasDi1Trace = !!data.hasDi1Trace && di1.length > 0;\n"
|
|
<< " const values = [];\n"
|
|
<< " const yCh1 = di1Grouped && ch1Grouped.length > 0 ? ch1Grouped : ch1;\n"
|
|
<< " const yCh2 = di1Grouped && ch2Grouped.length > 0 ? ch2Grouped : ch2;\n"
|
|
<< " const yRss = di1Grouped && rssGrouped.length > 0 ? rssGrouped : rss;\n"
|
|
<< " yCh1.forEach(p => values.push(p[1]));\n"
|
|
<< " if (hasCh2) yCh2.forEach(p => values.push(p[1]));\n"
|
|
<< " if (hasRss) yRss.forEach(p => values.push(p[1]));\n"
|
|
<< " let minY = Math.min(...values);\n"
|
|
<< " let maxY = Math.max(...values);\n"
|
|
<< " if (!Number.isFinite(minY) || !Number.isFinite(maxY) || minY === maxY) {\n"
|
|
<< " minY = -1; maxY = 1;\n"
|
|
<< " } else {\n"
|
|
<< " const pad = Math.max(0.05, (maxY - minY) * 0.08);\n"
|
|
<< " minY -= pad; maxY += pad;\n"
|
|
<< " }\n"
|
|
<< " const maxT = Math.max(data.durationMs / 1000.0, 1e-9);\n"
|
|
<< " latestAutoBounds = { minY, maxY, maxT };\n"
|
|
<< " const view = currentViewBounds(latestAutoBounds);\n"
|
|
<< " const plotCh1 = di1Grouped ? ch1Grouped : pickTrace(ch1, ch1Mid, ch1High, maxT, view);\n"
|
|
<< " const plotCh2 = hasCh2 ? (di1Grouped ? ch2Grouped : pickTrace(ch2, ch2Mid, ch2High, maxT, view)) : [];\n"
|
|
<< " const plotRss = hasRss ? (di1Grouped ? rssGrouped : pickTrace(rss, rssMid, rssHigh, maxT, view)) : [];\n"
|
|
<< " const box = drawAxes(view.yMin, view.yMax, view.xMin, view.xMax);\n"
|
|
<< " drawPacketMarkers(packetMarkers, box, view.xMin, view.xMax);\n"
|
|
<< " drawTrace(plotCh1, '#005bbb', box, view.yMin, view.yMax, view.xMin, view.xMax);\n"
|
|
<< " if (hasCh2) drawTrace(plotCh2, '#d62828', box, view.yMin, view.yMax, view.xMin, view.xMax);\n"
|
|
<< " if (hasRss) drawTrace(plotRss, '#ee8a12', box, view.yMin, view.yMax, view.xMin, view.xMax);\n"
|
|
<< " if (hasDi1Trace) drawDigitalTrace(di1, box, view.xMin, view.xMax);\n"
|
|
<< " legendCh2.style.display = hasCh2 ? '' : 'none';\n"
|
|
<< " legendRss.style.display = hasRss ? '' : 'none';\n"
|
|
<< " legendDi1.style.display = hasDi1Trace ? '' : 'none';\n"
|
|
<< " refreshAutoCheckbox();\n"
|
|
<< " const firstPacket = data.firstPacketIndex || data.packetIndex;\n"
|
|
<< " const lastPacket = data.lastPacketIndex || data.packetIndex;\n"
|
|
<< " packetInfo.textContent = 'Packets #' + firstPacket + '..' + lastPacket + ' of ' + data.packetsSeen + ', channels: ' + (data.channelCount || 2);\n"
|
|
<< " timingInfo.textContent = 'Window frames/ch: ' + data.framesPerChannel + ', window: ' + data.durationMs.toFixed(3) + ' ms, last packet: ' + data.latestPacketDurationMs.toFixed(3) + ' ms';\n"
|
|
<< " packetRateInfo.textContent = 'Packets/s: ' + data.packetsPerSecond.toFixed(3);\n"
|
|
<< " zeroInfo.textContent = hasDi1Trace\n"
|
|
<< " ? ('DI1 trace: ' + data.di1Frames + ' frame samples')\n"
|
|
<< " : ('Zeroed on DI1 change: ' + data.zeroedPercent.toFixed(3) + '% (' + data.zeroedSamples + '/' + data.storedSamples + ')');\n"
|
|
<< " zoomInfo.textContent = viewState.auto ? 'View: auto' : ('View: X ' + view.xMin.toFixed(6) + '..' + view.xMax.toFixed(6) + ' s, Y ' + view.yMin.toFixed(3) + '..' + view.yMax.toFixed(3) + ' V');\n"
|
|
<< " updateInfo.textContent = 'Snapshot: ' + data.updatedAt + (data.fullResLive ? ' | adaptive live resolution on' : '') + (di1Grouped ? (' | DI1-group avg: ' + (data.groupedPointCount || 0) + ' pts') : '');\n"
|
|
<< " statusText.textContent = 'Latest packet close reason: ' + data.closeReason + '. This page refreshes its rolling packet window every 500 ms.';\n"
|
|
<< " }\n"
|
|
<< " function applyLiveData(data) {\n"
|
|
<< " if (!data || data.status === 'waiting') {\n"
|
|
<< " renderWaiting((data && data.message) ? data.message : 'Waiting for first packet...');\n"
|
|
<< " } else {\n"
|
|
<< " renderPacket(data);\n"
|
|
<< " }\n"
|
|
<< " }\n"
|
|
<< " function loadLatestData() {\n"
|
|
<< " const oldScript = document.getElementById('liveDataScript');\n"
|
|
<< " if (oldScript) oldScript.remove();\n"
|
|
<< " window.e502LiveData = null;\n"
|
|
<< " const script = document.createElement('script');\n"
|
|
<< " script.id = 'liveDataScript';\n"
|
|
<< " const sep = dataScriptUrl.includes('?') ? '&' : '?';\n"
|
|
<< " script.src = dataScriptUrl + sep + 'ts=' + Date.now();\n"
|
|
<< " script.onload = () => {\n"
|
|
<< " applyLiveData(window.e502LiveData);\n"
|
|
<< " };\n"
|
|
<< " script.onerror = () => {\n"
|
|
<< " statusText.textContent = 'Cannot load live data script: ' + dataScriptUrl;\n"
|
|
<< " };\n"
|
|
<< " document.head.appendChild(script);\n"
|
|
<< " }\n"
|
|
<< " zoomXIn.addEventListener('click', () => applyZoomX(0.5));\n"
|
|
<< " zoomXOut.addEventListener('click', () => applyZoomX(2.0));\n"
|
|
<< " panLeft.addEventListener('click', () => applyPanX(-1.0));\n"
|
|
<< " panRight.addEventListener('click', () => applyPanX(1.0));\n"
|
|
<< " zoomYIn.addEventListener('click', () => applyZoomY(0.5));\n"
|
|
<< " zoomYOut.addEventListener('click', () => applyZoomY(2.0));\n"
|
|
<< " resetZoom.addEventListener('click', () => resetView());\n"
|
|
<< " autoZoom.addEventListener('change', () => {\n"
|
|
<< " if (autoZoom.checked) {\n"
|
|
<< " resetView();\n"
|
|
<< " } else if (latestAutoBounds && latestPacket) {\n"
|
|
<< " const current = currentViewBounds(latestAutoBounds);\n"
|
|
<< " viewState = { auto: false, xMin: current.xMin, xMax: current.xMax, yMin: current.yMin, yMax: current.yMax };\n"
|
|
<< " saveViewState();\n"
|
|
<< " renderPacket(latestPacket);\n"
|
|
<< " }\n"
|
|
<< " });\n"
|
|
<< " canvas.addEventListener('wheel', (event) => {\n"
|
|
<< " if (!latestPacket) return;\n"
|
|
<< " event.preventDefault();\n"
|
|
<< " const factor = event.deltaY < 0 ? 0.8 : 1.25;\n"
|
|
<< " if (event.shiftKey) {\n"
|
|
<< " applyZoomY(factor);\n"
|
|
<< " } else {\n"
|
|
<< " applyZoomX(factor);\n"
|
|
<< " }\n"
|
|
<< " }, { passive: false });\n"
|
|
<< " canvas.addEventListener('dblclick', () => resetView());\n"
|
|
<< " window.addEventListener('keydown', (event) => {\n"
|
|
<< " if (!latestPacket) return;\n"
|
|
<< " if (event.key === 'ArrowLeft') {\n"
|
|
<< " event.preventDefault();\n"
|
|
<< " applyPanX(-1.0);\n"
|
|
<< " } else if (event.key === 'ArrowRight') {\n"
|
|
<< " event.preventDefault();\n"
|
|
<< " applyPanX(1.0);\n"
|
|
<< " }\n"
|
|
<< " });\n"
|
|
<< " renderWaiting('Waiting for first packet...');\n"
|
|
<< " loadLatestData();\n"
|
|
<< " window.setInterval(loadLatestData, 500);\n"
|
|
<< " </script>\n"
|
|
<< "</body>\n"
|
|
<< "</html>\n";
|
|
}
|
|
|
|
[[noreturn]] void fail_write(const std::string& message) {
|
|
throw std::runtime_error(message);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
CaptureFileWriter::CaptureFileWriter(std::string csv_path,
|
|
std::string svg_path,
|
|
std::string live_html_path,
|
|
std::string live_json_path,
|
|
bool full_res_live,
|
|
std::size_t live_history_packets,
|
|
bool di1_group_average)
|
|
: csv_path_(std::move(csv_path)),
|
|
svg_path_(std::move(svg_path)),
|
|
live_html_path_(std::move(live_html_path)),
|
|
live_json_path_(std::move(live_json_path)),
|
|
full_res_live_(full_res_live),
|
|
live_history_packets_(live_history_packets),
|
|
di1_group_average_(di1_group_average) {}
|
|
|
|
const std::string& CaptureFileWriter::live_html_path() const {
|
|
return live_html_path_;
|
|
}
|
|
|
|
const std::string& CaptureFileWriter::live_json_path() const {
|
|
return live_json_path_;
|
|
}
|
|
|
|
void CaptureFileWriter::write(const std::vector<CapturePacket>& packets,
|
|
double frame_freq_hz,
|
|
double nominal_range_v) const {
|
|
finalize_csv_from_spool(frame_freq_hz);
|
|
write_svg(packets, frame_freq_hz, nominal_range_v);
|
|
}
|
|
|
|
void CaptureFileWriter::initialize_live_plot() const {
|
|
const std::string waiting_json =
|
|
"{\n"
|
|
" \"status\": \"waiting\",\n"
|
|
" \"message\": \"Waiting for first packet...\"\n"
|
|
"}\n";
|
|
const std::string live_script_path = replace_extension(live_json_path_, ".js");
|
|
write_text_file(live_json_path_, waiting_json);
|
|
write_live_data_script(live_script_path, waiting_json);
|
|
write_live_html_document(live_html_path_, path_to_script_url(live_script_path));
|
|
}
|
|
|
|
void CaptureFileWriter::initialize_csv(std::size_t channel_count, bool has_di1_trace) const {
|
|
std::ofstream csv_file(csv_path_, std::ios::binary | std::ios::trunc);
|
|
if (!csv_file) {
|
|
fail_write("Cannot prepare CSV output file: " + csv_path_);
|
|
}
|
|
csv_file << "";
|
|
|
|
const std::string spool_path = csv_spool_path(csv_path_);
|
|
std::ofstream spool(spool_path, std::ios::binary | std::ios::trunc);
|
|
if (!spool) {
|
|
fail_write("Cannot open CSV spool for writing: " + spool_path);
|
|
}
|
|
const CsvSpoolFileHeader header{
|
|
kCsvSpoolMagic,
|
|
kCsvSpoolVersion,
|
|
static_cast<uint32_t>(channel_count),
|
|
static_cast<uint32_t>(has_di1_trace ? 1U : 0U)
|
|
};
|
|
spool.write(reinterpret_cast<const char*>(&header), sizeof(header));
|
|
if (!spool) {
|
|
fail_write("Cannot initialize CSV spool: " + spool_path);
|
|
}
|
|
}
|
|
|
|
void CaptureFileWriter::append_csv_packet(const CapturePacket& packet,
|
|
double frame_freq_hz,
|
|
std::size_t& global_frame_index) const {
|
|
(void) frame_freq_hz;
|
|
(void) global_frame_index;
|
|
|
|
const std::string spool_path = csv_spool_path(csv_path_);
|
|
std::ofstream spool(spool_path, std::ios::binary | std::ios::app);
|
|
if (!spool) {
|
|
fail_write("Cannot open CSV spool for appending: " + spool_path);
|
|
}
|
|
|
|
const std::size_t frames = packet_frame_count(packet);
|
|
const std::size_t channel_count = packet_channel_count(packet);
|
|
const CsvSpoolPacketHeader header{
|
|
static_cast<uint64_t>(packet.packet_index),
|
|
static_cast<uint64_t>(channel_count),
|
|
static_cast<uint64_t>(frames),
|
|
static_cast<uint64_t>(packet_has_di1_trace(packet) ? 1U : 0U)
|
|
};
|
|
spool.write(reinterpret_cast<const char*>(&header), sizeof(header));
|
|
if (frames != 0U) {
|
|
spool.write(reinterpret_cast<const char*>(packet.ch1.data()),
|
|
static_cast<std::streamsize>(frames * sizeof(double)));
|
|
if (channel_count >= 2U) {
|
|
spool.write(reinterpret_cast<const char*>(packet.ch2.data()),
|
|
static_cast<std::streamsize>(frames * sizeof(double)));
|
|
}
|
|
if (packet_has_di1_trace(packet)) {
|
|
std::vector<uint8_t> di1_bytes(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
di1_bytes[i] = packet.di1[i] ? 1U : 0U;
|
|
}
|
|
spool.write(reinterpret_cast<const char*>(di1_bytes.data()),
|
|
static_cast<std::streamsize>(frames * sizeof(uint8_t)));
|
|
}
|
|
}
|
|
if (!spool) {
|
|
fail_write("Cannot append packet to CSV spool: " + spool_path);
|
|
}
|
|
}
|
|
|
|
void CaptureFileWriter::update_live_plot(const std::deque<CapturePacket>& packets,
|
|
std::size_t packets_seen,
|
|
double packets_per_second,
|
|
double frame_freq_hz,
|
|
const std::string& close_reason,
|
|
std::size_t zeroed_samples,
|
|
std::size_t stored_samples) const {
|
|
if (packets.empty()) {
|
|
return;
|
|
}
|
|
|
|
const std::size_t begin_index =
|
|
((live_history_packets_ != 0U) && (packets.size() > live_history_packets_))
|
|
? (packets.size() - live_history_packets_)
|
|
: 0U;
|
|
const CapturePacket& latest_packet = packets.back();
|
|
const std::size_t latest_frames = packet_frame_count(latest_packet);
|
|
const double latest_duration_ms =
|
|
(latest_frames == 0U) ? 0.0 : (1000.0 * static_cast<double>(latest_frames) / frame_freq_hz);
|
|
const double zeroed_percent = (stored_samples == 0U)
|
|
? 0.0
|
|
: (100.0 * static_cast<double>(zeroed_samples) / static_cast<double>(stored_samples));
|
|
|
|
std::size_t total_frames = 0;
|
|
std::vector<std::pair<std::size_t, std::size_t>> packet_markers;
|
|
std::vector<double> ch1_values;
|
|
std::vector<double> ch2_values;
|
|
std::vector<uint8_t> di1_values;
|
|
|
|
for (std::size_t i = begin_index; i < packets.size(); ++i) {
|
|
total_frames += packet_frame_count(packets[i]);
|
|
}
|
|
|
|
ch1_values.reserve(total_frames);
|
|
if (packet_has_ch2(latest_packet)) {
|
|
ch2_values.reserve(total_frames);
|
|
}
|
|
if (packet_has_di1_trace(latest_packet)) {
|
|
di1_values.reserve(total_frames);
|
|
}
|
|
|
|
std::size_t frame_offset = 0;
|
|
bool first_packet = true;
|
|
for (std::size_t i = begin_index; i < packets.size(); ++i) {
|
|
const CapturePacket& packet = packets[i];
|
|
const std::size_t frames = packet_frame_count(packet);
|
|
if (frames == 0U) {
|
|
continue;
|
|
}
|
|
|
|
if (!first_packet) {
|
|
packet_markers.push_back({frame_offset, packet.packet_index});
|
|
}
|
|
first_packet = false;
|
|
|
|
ch1_values.insert(ch1_values.end(), packet.ch1.begin(), packet.ch1.begin() + static_cast<std::ptrdiff_t>(frames));
|
|
if (packet_has_ch2(packet)) {
|
|
ch2_values.insert(ch2_values.end(), packet.ch2.begin(), packet.ch2.begin() + static_cast<std::ptrdiff_t>(frames));
|
|
}
|
|
if (packet_has_di1_trace(packet)) {
|
|
di1_values.insert(di1_values.end(), packet.di1.begin(), packet.di1.begin() + static_cast<std::ptrdiff_t>(frames));
|
|
}
|
|
frame_offset += frames;
|
|
}
|
|
|
|
const double duration_ms = (total_frames == 0U) ? 0.0 : (1000.0 * static_cast<double>(total_frames) / frame_freq_hz);
|
|
std::vector<double> rss_values;
|
|
if (packet_has_ch2(latest_packet)) {
|
|
rss_values.reserve(total_frames);
|
|
for (std::size_t i = 0; i < total_frames; ++i) {
|
|
const double ch1 = ch1_values[i];
|
|
const double ch2 = ch2_values[i];
|
|
rss_values.push_back(std::sqrt((ch1 * ch1) + (ch2 * ch2)));
|
|
}
|
|
}
|
|
const auto trace1 = build_min_max_trace(ch1_values, kLivePlotMaxColumns);
|
|
const auto trace2 = build_min_max_trace(ch2_values, kLivePlotMaxColumns);
|
|
const auto rss_trace = build_min_max_trace(rss_values, kLivePlotMaxColumns);
|
|
const auto di1_trace = build_digital_step_trace(di1_values);
|
|
const bool di1_grouped = di1_group_average_ && !di1_values.empty();
|
|
const auto grouped_traces = di1_grouped
|
|
? build_di1_grouped_traces(ch1_values, ch2_values, di1_values, packet_has_ch2(latest_packet))
|
|
: Di1GroupedTraces{};
|
|
const auto trace1_mid = full_res_live_ ? build_min_max_trace(ch1_values, kLivePlotMidColumns) : std::vector<PlotPoint>{};
|
|
const auto trace2_mid = full_res_live_ ? build_min_max_trace(ch2_values, kLivePlotMidColumns) : std::vector<PlotPoint>{};
|
|
const auto rss_trace_mid = full_res_live_ ? build_min_max_trace(rss_values, kLivePlotMidColumns) : std::vector<PlotPoint>{};
|
|
const auto trace1_high = full_res_live_
|
|
? ((total_frames <= kLivePlotRawFrameLimit) ? build_full_trace(ch1_values) : build_min_max_trace(ch1_values, kLivePlotHighColumns))
|
|
: std::vector<PlotPoint>{};
|
|
const auto trace2_high = full_res_live_
|
|
? ((total_frames <= kLivePlotRawFrameLimit) ? build_full_trace(ch2_values) : build_min_max_trace(ch2_values, kLivePlotHighColumns))
|
|
: std::vector<PlotPoint>{};
|
|
const auto rss_trace_high = full_res_live_
|
|
? ((total_frames <= kLivePlotRawFrameLimit) ? build_full_trace(rss_values) : build_min_max_trace(rss_values, kLivePlotHighColumns))
|
|
: std::vector<PlotPoint>{};
|
|
|
|
std::ostringstream json;
|
|
json << std::fixed << std::setprecision(9);
|
|
json << "{\n"
|
|
<< " \"status\": \"packet\",\n"
|
|
<< " \"packetIndex\": " << latest_packet.packet_index << ",\n"
|
|
<< " \"firstPacketIndex\": " << packets[begin_index].packet_index << ",\n"
|
|
<< " \"lastPacketIndex\": " << latest_packet.packet_index << ",\n"
|
|
<< " \"windowPacketCount\": " << (packets.size() - begin_index) << ",\n"
|
|
<< " \"channelCount\": " << packet_channel_count(latest_packet) << ",\n"
|
|
<< " \"packetsSeen\": " << packets_seen << ",\n"
|
|
<< " \"packetsPerSecond\": " << packets_per_second << ",\n"
|
|
<< " \"framesPerChannel\": " << total_frames << ",\n"
|
|
<< " \"latestPacketFrames\": " << latest_frames << ",\n"
|
|
<< " \"frameFreqHz\": " << frame_freq_hz << ",\n"
|
|
<< " \"durationMs\": " << duration_ms << ",\n"
|
|
<< " \"latestPacketDurationMs\": " << latest_duration_ms << ",\n"
|
|
<< " \"closeReason\": \"" << close_reason << "\",\n"
|
|
<< " \"zeroedSamples\": " << zeroed_samples << ",\n"
|
|
<< " \"storedSamples\": " << stored_samples << ",\n"
|
|
<< " \"zeroedPercent\": " << zeroed_percent << ",\n"
|
|
<< " \"fullResLive\": " << (full_res_live_ ? "true" : "false") << ",\n"
|
|
<< " \"di1Grouped\": " << (di1_grouped ? "true" : "false") << ",\n"
|
|
<< " \"groupedPointCount\": " << grouped_traces.ch1.size() << ",\n"
|
|
<< " \"hasDi1Trace\": " << (packet_has_di1_trace(latest_packet) ? "true" : "false") << ",\n"
|
|
<< " \"di1Frames\": " << di1_values.size() << ",\n"
|
|
<< " \"updatedAt\": \"packet " << latest_packet.packet_index << "\",\n"
|
|
<< " \"ch1\": " << json_points(trace1, frame_freq_hz) << ",\n"
|
|
<< " \"ch1Mid\": " << json_points(trace1_mid, frame_freq_hz) << ",\n"
|
|
<< " \"ch1High\": " << json_points(trace1_high, frame_freq_hz) << ",\n"
|
|
<< " \"ch2\": " << json_points(trace2, frame_freq_hz) << ",\n"
|
|
<< " \"ch2Mid\": " << json_points(trace2_mid, frame_freq_hz) << ",\n"
|
|
<< " \"ch2High\": " << json_points(trace2_high, frame_freq_hz) << ",\n"
|
|
<< " \"rss\": " << json_points(rss_trace, frame_freq_hz) << ",\n"
|
|
<< " \"rssMid\": " << json_points(rss_trace_mid, frame_freq_hz) << ",\n"
|
|
<< " \"rssHigh\": " << json_points(rss_trace_high, frame_freq_hz) << ",\n"
|
|
<< " \"ch1Grouped\": " << json_points(grouped_traces.ch1, frame_freq_hz) << ",\n"
|
|
<< " \"ch2Grouped\": " << json_points(grouped_traces.ch2, frame_freq_hz) << ",\n"
|
|
<< " \"rssGrouped\": " << json_points(grouped_traces.rss, frame_freq_hz) << ",\n"
|
|
<< " \"di1\": " << json_points(di1_trace, frame_freq_hz) << ",\n"
|
|
<< " \"packetMarkers\": " << json_packet_markers(packet_markers, frame_freq_hz) << "\n"
|
|
<< "}\n";
|
|
|
|
const std::string json_text = json.str();
|
|
write_text_file(live_json_path_, json_text);
|
|
write_live_data_script(replace_extension(live_json_path_, ".js"), json_text);
|
|
}
|
|
|
|
void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const {
|
|
const std::string spool_path = csv_spool_path(csv_path_);
|
|
std::ifstream spool(spool_path, std::ios::binary);
|
|
if (!spool) {
|
|
fail_write("Cannot open CSV spool for reading: " + spool_path);
|
|
}
|
|
|
|
CsvSpoolFileHeader file_header{};
|
|
spool.read(reinterpret_cast<char*>(&file_header), sizeof(file_header));
|
|
if (!spool || (file_header.magic != kCsvSpoolMagic) || (file_header.version != kCsvSpoolVersion)) {
|
|
fail_write("Invalid CSV spool format: " + spool_path);
|
|
}
|
|
|
|
std::ofstream csv(csv_path_, std::ios::binary | std::ios::trunc);
|
|
if (!csv) {
|
|
fail_write("Cannot open CSV for final writing: " + csv_path_);
|
|
}
|
|
|
|
bool header_written = false;
|
|
std::size_t global_frame_index = 0;
|
|
|
|
while (true) {
|
|
CsvSpoolPacketHeader packet_header{};
|
|
spool.read(reinterpret_cast<char*>(&packet_header), sizeof(packet_header));
|
|
if (!spool) {
|
|
if (spool.eof()) {
|
|
break;
|
|
}
|
|
fail_write("Cannot read packet header from CSV spool: " + spool_path);
|
|
}
|
|
|
|
const std::size_t packet_index = static_cast<std::size_t>(packet_header.packet_index);
|
|
const std::size_t channel_count = static_cast<std::size_t>(packet_header.channel_count);
|
|
const std::size_t frames = static_cast<std::size_t>(packet_header.frame_count);
|
|
const bool has_di1_trace = packet_header.has_di1_trace != 0U;
|
|
|
|
if (!header_written) {
|
|
csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v";
|
|
if (channel_count >= 2U) {
|
|
csv << ",ch2_v,rss_v";
|
|
}
|
|
if (has_di1_trace || (file_header.has_di1_trace != 0U)) {
|
|
csv << ",di1";
|
|
}
|
|
csv << "\n";
|
|
header_written = true;
|
|
}
|
|
|
|
std::vector<double> ch1(frames);
|
|
std::vector<double> ch2((channel_count >= 2U) ? frames : 0U);
|
|
std::vector<uint8_t> di1(has_di1_trace ? frames : 0U);
|
|
if (frames != 0U) {
|
|
spool.read(reinterpret_cast<char*>(ch1.data()), static_cast<std::streamsize>(frames * sizeof(double)));
|
|
if (!spool) {
|
|
fail_write("Cannot read CH1 data from CSV spool: " + spool_path);
|
|
}
|
|
if (channel_count >= 2U) {
|
|
spool.read(reinterpret_cast<char*>(ch2.data()), static_cast<std::streamsize>(frames * sizeof(double)));
|
|
if (!spool) {
|
|
fail_write("Cannot read CH2 data from CSV spool: " + spool_path);
|
|
}
|
|
}
|
|
if (has_di1_trace) {
|
|
spool.read(reinterpret_cast<char*>(di1.data()), static_cast<std::streamsize>(frames * sizeof(uint8_t)));
|
|
if (!spool) {
|
|
fail_write("Cannot read DI1 trace from CSV spool: " + spool_path);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(9);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
const double time_s = static_cast<double>(global_frame_index) / frame_freq_hz;
|
|
const double packet_time_s = static_cast<double>(i) / frame_freq_hz;
|
|
out << packet_index << "," << i << "," << global_frame_index << ","
|
|
<< time_s << "," << packet_time_s << "," << ch1[i];
|
|
if (channel_count >= 2U) {
|
|
const double rss = std::sqrt((ch1[i] * ch1[i]) + (ch2[i] * ch2[i]));
|
|
out << "," << ch2[i] << "," << rss;
|
|
}
|
|
if (has_di1_trace || (file_header.has_di1_trace != 0U)) {
|
|
out << "," << (has_di1_trace ? static_cast<unsigned>(di1[i]) : 0U);
|
|
}
|
|
out << "\n";
|
|
++global_frame_index;
|
|
}
|
|
csv << out.str();
|
|
}
|
|
|
|
if (!header_written) {
|
|
csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v";
|
|
if (file_header.channel_count >= 2U) {
|
|
csv << ",ch2_v,rss_v";
|
|
}
|
|
if (file_header.has_di1_trace != 0U) {
|
|
csv << ",di1";
|
|
}
|
|
csv << "\n";
|
|
}
|
|
|
|
csv.flush();
|
|
std::remove(spool_path.c_str());
|
|
}
|
|
|
|
void CaptureFileWriter::write_svg(const std::vector<CapturePacket>& packets,
|
|
double frame_freq_hz,
|
|
double nominal_range_v) const {
|
|
std::ofstream file(svg_path_, std::ios::binary);
|
|
if (!file) {
|
|
fail_write("Cannot open SVG for writing: " + svg_path_);
|
|
}
|
|
|
|
std::size_t total_frames = 0;
|
|
double min_y = std::numeric_limits<double>::infinity();
|
|
double max_y = -std::numeric_limits<double>::infinity();
|
|
const std::size_t channel_count = packets.empty() ? 2U : packet_channel_count(packets.front());
|
|
const bool has_di1_trace = std::any_of(packets.begin(), packets.end(), [](const CapturePacket& packet) {
|
|
return packet_has_di1_trace(packet);
|
|
});
|
|
for (const auto& packet : packets) {
|
|
const std::size_t frames = packet_frame_count(packet);
|
|
total_frames += frames;
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
min_y = std::min(min_y, packet.ch1[i]);
|
|
max_y = std::max(max_y, packet.ch1[i]);
|
|
if (packet_has_ch2(packet)) {
|
|
min_y = std::min(min_y, packet.ch2[i]);
|
|
max_y = std::max(max_y, packet.ch2[i]);
|
|
const double rss = std::sqrt((packet.ch1[i] * packet.ch1[i]) + (packet.ch2[i] * packet.ch2[i]));
|
|
min_y = std::min(min_y, rss);
|
|
max_y = std::max(max_y, rss);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!std::isfinite(min_y) || !std::isfinite(max_y) || (min_y == max_y)) {
|
|
min_y = -nominal_range_v;
|
|
max_y = nominal_range_v;
|
|
} else {
|
|
const double pad = std::max(0.1, (max_y - min_y) * 0.08);
|
|
min_y -= pad;
|
|
max_y += pad;
|
|
}
|
|
|
|
const double total_time_s = (total_frames > 1U) ? (static_cast<double>(total_frames - 1U) / frame_freq_hz) : 0.0;
|
|
const double width = 1400.0;
|
|
const double height = 800.0;
|
|
const double left = 90.0;
|
|
const double right = 40.0;
|
|
const double top = 40.0;
|
|
const double bottom = 80.0;
|
|
const double plot_w = width - left - right;
|
|
const double plot_h = height - top - bottom;
|
|
const double zero_y = top + plot_h - ((0.0 - min_y) / std::max(1e-12, max_y - min_y)) * plot_h;
|
|
const double di1_band_top = top + 10.0;
|
|
const double di1_band_height = 44.0;
|
|
|
|
file << "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" << width
|
|
<< "\" height=\"" << height << "\" viewBox=\"0 0 " << width << " " << height << "\">\n";
|
|
file << " <rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n";
|
|
file << " <rect x=\"" << left << "\" y=\"" << top << "\" width=\"" << plot_w
|
|
<< "\" height=\"" << plot_h << "\" fill=\"#fbfcfe\" stroke=\"#ccd4dd\"/>\n";
|
|
|
|
for (int i = 0; i <= 10; ++i) {
|
|
const double x = left + (plot_w * i / 10.0);
|
|
const double y = top + (plot_h * i / 10.0);
|
|
file << " <line x1=\"" << x << "\" y1=\"" << top << "\" x2=\"" << x << "\" y2=\"" << (top + plot_h)
|
|
<< "\" stroke=\"#edf1f5\" stroke-width=\"1\"/>\n";
|
|
file << " <line x1=\"" << left << "\" y1=\"" << y << "\" x2=\"" << (left + plot_w) << "\" y2=\"" << y
|
|
<< "\" stroke=\"#edf1f5\" stroke-width=\"1\"/>\n";
|
|
}
|
|
|
|
if ((0.0 >= min_y) && (0.0 <= max_y)) {
|
|
file << " <line x1=\"" << left << "\" y1=\"" << zero_y << "\" x2=\"" << (left + plot_w)
|
|
<< "\" y2=\"" << zero_y << "\" stroke=\"#8fa1b3\" stroke-width=\"1.2\"/>\n";
|
|
}
|
|
if (has_di1_trace) {
|
|
file << " <rect x=\"" << left << "\" y=\"" << di1_band_top << "\" width=\"" << plot_w
|
|
<< "\" height=\"" << di1_band_height << "\" fill=\"#edf8f0\" opacity=\"0.85\"/>\n";
|
|
file << " <rect x=\"" << left << "\" y=\"" << di1_band_top << "\" width=\"" << plot_w
|
|
<< "\" height=\"" << di1_band_height << "\" fill=\"none\" stroke=\"#9fcdab\" stroke-width=\"1\"/>\n";
|
|
file << " <text x=\"" << (left + 8.0) << "\" y=\"" << (di1_band_top + 15.0)
|
|
<< "\" font-size=\"12\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#1b8f3a\">DI1</text>\n";
|
|
}
|
|
|
|
std::size_t frame_offset = 0;
|
|
for (std::size_t packet_idx = 0; packet_idx < packets.size(); ++packet_idx) {
|
|
const auto& packet = packets[packet_idx];
|
|
const std::size_t frames = packet_frame_count(packet);
|
|
if (frames == 0U) {
|
|
continue;
|
|
}
|
|
|
|
const double x0 = left + ((total_frames > 1U)
|
|
? (static_cast<double>(frame_offset) / static_cast<double>(total_frames - 1U)) * plot_w
|
|
: 0.0);
|
|
const double x1 = left + ((total_frames > 1U)
|
|
? (static_cast<double>(frame_offset + frames - 1U) / static_cast<double>(total_frames - 1U)) * plot_w
|
|
: plot_w);
|
|
const char* packet_fill = (packet_idx % 2U == 0U) ? "#f7fafc" : "#fdfefe";
|
|
file << " <rect x=\"" << x0 << "\" y=\"" << top << "\" width=\"" << std::max(1.0, x1 - x0)
|
|
<< "\" height=\"" << plot_h << "\" fill=\"" << packet_fill << "\" opacity=\"0.55\"/>\n";
|
|
if (packet_idx != 0U) {
|
|
file << " <line x1=\"" << x0 << "\" y1=\"" << top << "\" x2=\"" << x0 << "\" y2=\"" << (top + plot_h)
|
|
<< "\" stroke=\"#c8d2dc\" stroke-width=\"1.2\" stroke-dasharray=\"4 4\"/>\n";
|
|
}
|
|
|
|
const auto trace1 = build_min_max_trace(packet.ch1, 1200);
|
|
const auto trace2 = build_min_max_trace(packet.ch2, 1200);
|
|
const auto rss_values = build_rss_values(packet);
|
|
const auto trace_rss = build_min_max_trace(rss_values, 1200);
|
|
const bool di1_grouped = di1_group_average_ && packet_has_di1_trace(packet);
|
|
const auto grouped_traces = di1_grouped
|
|
? build_di1_grouped_traces(packet.ch1, packet.ch2, packet.di1, packet_has_ch2(packet))
|
|
: Di1GroupedTraces{};
|
|
const auto trace_di1 = build_digital_step_trace(packet.di1);
|
|
const auto& plot_trace1 = di1_grouped ? grouped_traces.ch1 : trace1;
|
|
const auto& plot_trace2 = di1_grouped ? grouped_traces.ch2 : trace2;
|
|
const auto& plot_trace_rss = di1_grouped ? grouped_traces.rss : trace_rss;
|
|
file << " <polyline fill=\"none\" stroke=\"#005bbb\" stroke-width=\"1.2\" points=\""
|
|
<< polyline_points(plot_trace1, frame_offset, total_frames, min_y, max_y, left, top, plot_w, plot_h)
|
|
<< "\"/>\n";
|
|
if (packet_has_ch2(packet)) {
|
|
file << " <polyline fill=\"none\" stroke=\"#d62828\" stroke-width=\"1.2\" points=\""
|
|
<< polyline_points(plot_trace2, frame_offset, total_frames, min_y, max_y, left, top, plot_w, plot_h)
|
|
<< "\"/>\n";
|
|
file << " <polyline fill=\"none\" stroke=\"#ee8a12\" stroke-width=\"1.2\" points=\""
|
|
<< polyline_points(plot_trace_rss, frame_offset, total_frames, min_y, max_y, left, top, plot_w, plot_h)
|
|
<< "\"/>\n";
|
|
}
|
|
if (packet_has_di1_trace(packet)) {
|
|
file << " <polyline fill=\"none\" stroke=\"#1b8f3a\" stroke-width=\"1.3\" points=\""
|
|
<< digital_polyline_points(trace_di1, frame_offset, total_frames, left, di1_band_top, plot_w, di1_band_height)
|
|
<< "\"/>\n";
|
|
}
|
|
|
|
if (packets.size() <= 24U) {
|
|
file << " <text x=\"" << (x0 + 6.0) << "\" y=\"" << (top + 18.0)
|
|
<< "\" font-size=\"12\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#5c6f82\">P"
|
|
<< packet.packet_index << "</text>\n";
|
|
}
|
|
|
|
frame_offset += frames;
|
|
}
|
|
|
|
file << " <text x=\"" << left << "\" y=\"24\" font-size=\"22\" font-family=\"Segoe UI, Arial, sans-serif\""
|
|
<< " fill=\"#203040\">E-502 capture: " << packets.size() << " packet(s), "
|
|
<< ((channel_count >= 2U) ? "CH1 and CH2" : "CH1 only");
|
|
if (has_di1_trace) {
|
|
file << ", DI1 trace";
|
|
}
|
|
if (di1_group_average_ && has_di1_trace) {
|
|
file << ", averaged by DI1 runs";
|
|
}
|
|
file << "</text>\n";
|
|
file << " <text x=\"" << left << "\" y=\"" << (height - 22)
|
|
<< "\" font-size=\"16\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#425466\">time, s</text>\n";
|
|
file << " <text x=\"18\" y=\"" << (top + 16)
|
|
<< "\" font-size=\"16\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#425466\">V</text>\n";
|
|
|
|
file << std::fixed << std::setprecision(6);
|
|
for (int i = 0; i <= 10; ++i) {
|
|
const double x = left + (plot_w * i / 10.0);
|
|
const double t = total_time_s * i / 10.0;
|
|
file << " <text x=\"" << x << "\" y=\"" << (top + plot_h + 24)
|
|
<< "\" text-anchor=\"middle\" font-size=\"12\" font-family=\"Segoe UI, Arial, sans-serif\""
|
|
<< " fill=\"#607080\">" << t << "</text>\n";
|
|
|
|
const double y = top + plot_h - (plot_h * i / 10.0);
|
|
const double v = min_y + (max_y - min_y) * i / 10.0;
|
|
file << " <text x=\"" << (left - 10) << "\" y=\"" << (y + 4)
|
|
<< "\" text-anchor=\"end\" font-size=\"12\" font-family=\"Segoe UI, Arial, sans-serif\""
|
|
<< " fill=\"#607080\">" << v << "</text>\n";
|
|
}
|
|
|
|
const double legend_y = height - 48.0;
|
|
file << " <line x1=\"" << (width - 270) << "\" y1=\"" << legend_y
|
|
<< "\" x2=\"" << (width - 230) << "\" y2=\"" << legend_y
|
|
<< "\" stroke=\"#005bbb\" stroke-width=\"3\"/>\n";
|
|
file << " <text x=\"" << (width - 220) << "\" y=\"" << (legend_y + 4)
|
|
<< "\" font-size=\"14\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#203040\">CH1</text>\n";
|
|
if (channel_count >= 2U) {
|
|
file << " <line x1=\"" << (width - 160) << "\" y1=\"" << legend_y
|
|
<< "\" x2=\"" << (width - 120) << "\" y2=\"" << legend_y
|
|
<< "\" stroke=\"#d62828\" stroke-width=\"3\"/>\n";
|
|
file << " <text x=\"" << (width - 110) << "\" y=\"" << (legend_y + 4)
|
|
<< "\" font-size=\"14\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#203040\">CH2</text>\n";
|
|
file << " <line x1=\"" << (width - 360) << "\" y1=\"" << legend_y
|
|
<< "\" x2=\"" << (width - 320) << "\" y2=\"" << legend_y
|
|
<< "\" stroke=\"#ee8a12\" stroke-width=\"3\"/>\n";
|
|
file << " <text x=\"" << (width - 310) << "\" y=\"" << (legend_y + 4)
|
|
<< "\" font-size=\"14\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#203040\">sqrt(CH1^2+CH2^2)</text>\n";
|
|
}
|
|
if (has_di1_trace) {
|
|
file << " <line x1=\"" << (width - 80) << "\" y1=\"" << legend_y
|
|
<< "\" x2=\"" << (width - 40) << "\" y2=\"" << legend_y
|
|
<< "\" stroke=\"#1b8f3a\" stroke-width=\"3\"/>\n";
|
|
file << " <text x=\"" << (width - 30) << "\" y=\"" << (legend_y + 4)
|
|
<< "\" font-size=\"14\" font-family=\"Segoe UI, Arial, sans-serif\" fill=\"#203040\">DI1</text>\n";
|
|
}
|
|
file << "</svg>\n";
|
|
}
|