#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 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;
};

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::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_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();
}

[[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 new packets.</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=\"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, double-click: reset</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=\"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 legendDi1 = document.getElementById('legendDi1');\n"
         << "    const zoomXIn = document.getElementById('zoomXIn');\n"
         << "    const zoomXOut = document.getElementById('zoomXOut');\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 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 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"
         << "      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 ch2 = data.ch2 || [];\n"
         << "      const di1 = data.di1 || [];\n"
         << "      const hasCh2 = (data.channelCount || 2) > 1 && ch2.length > 0;\n"
         << "      const hasDi1Trace = !!data.hasDi1Trace && di1.length > 0;\n"
         << "      const values = [];\n"
         << "      ch1.forEach(p => values.push(p[1]));\n"
         << "      if (hasCh2) ch2.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 box = drawAxes(view.yMin, view.yMax, view.xMin, view.xMax);\n"
         << "      drawTrace(ch1, '#005bbb', box, view.yMin, view.yMax, view.xMin, view.xMax);\n"
         << "      if (hasCh2) drawTrace(ch2, '#d62828', 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"
         << "      legendDi1.style.display = hasDi1Trace ? '' : 'none';\n"
         << "      refreshAutoCheckbox();\n"
         << "      packetInfo.textContent = 'Packet #' + data.packetIndex + ' of ' + data.packetsSeen + ', channels: ' + (data.channelCount || 2);\n"
         << "      timingInfo.textContent = 'Frames/ch: ' + data.framesPerChannel + ', duration: ' + data.durationMs.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;\n"
         << "      statusText.textContent = 'Close reason: ' + data.closeReason + '. This page refreshes its data 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"
         << "    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"
         << "    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)
    : 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)) {}

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 CapturePacket& packet,
                                         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 {
    const std::size_t frames = packet_frame_count(packet);
    const double duration_ms = (frames == 0U) ? 0.0 : (1000.0 * static_cast<double>(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));
    const auto trace1 = build_min_max_trace(packet.ch1, kLivePlotMaxColumns);
    const auto trace2 = build_min_max_trace(packet.ch2, kLivePlotMaxColumns);
    const auto di1_trace = build_digital_step_trace(packet.di1);

    std::ostringstream json;
    json << std::fixed << std::setprecision(9);
    json << "{\n"
         << "  \"status\": \"packet\",\n"
         << "  \"packetIndex\": " << packet.packet_index << ",\n"
         << "  \"channelCount\": " << packet_channel_count(packet) << ",\n"
         << "  \"packetsSeen\": " << packets_seen << ",\n"
         << "  \"packetsPerSecond\": " << packets_per_second << ",\n"
         << "  \"framesPerChannel\": " << frames << ",\n"
         << "  \"frameFreqHz\": " << frame_freq_hz << ",\n"
         << "  \"durationMs\": " << duration_ms << ",\n"
         << "  \"closeReason\": \"" << close_reason << "\",\n"
         << "  \"zeroedSamples\": " << zeroed_samples << ",\n"
         << "  \"storedSamples\": " << stored_samples << ",\n"
         << "  \"zeroedPercent\": " << zeroed_percent << ",\n"
         << "  \"hasDi1Trace\": " << (packet_has_di1_trace(packet) ? "true" : "false") << ",\n"
         << "  \"di1Frames\": " << packet.di1.size() << ",\n"
         << "  \"updatedAt\": \"packet " << packet.packet_index << "\",\n"
         << "  \"ch1\": " << json_points(trace1, frame_freq_hz) << ",\n"
         << "  \"ch2\": " << json_points(trace2, frame_freq_hz) << ",\n"
         << "  \"di1\": " << json_points(di1_trace, 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";
            }
            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) {
                out << "," << ch2[i];
            }
            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";
        }
        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]);
            }
        }
    }

    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 trace_di1 = build_digital_step_trace(packet.di1);
        file << "  <polyline fill=\"none\" stroke=\"#005bbb\" stroke-width=\"1.2\" points=\""
             << polyline_points(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(trace2, 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";
    }
    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";
    }
    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";
}
