diff --git a/capture_file_writer.cpp b/capture_file_writer.cpp index 6f6767e..758c47b 100644 --- a/capture_file_writer.cpp +++ b/capture_file_writer.cpp @@ -141,6 +141,12 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " .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" @@ -157,6 +163,7 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " \n" << " \n" << " \n" + << " \n" << " \n" << " \n" << "
\n" @@ -164,6 +171,15 @@ void write_live_html_document(const std::string& path, const std::string& data_j << "
\n" << " \n" << "
Open this page once and leave it open. It reloads itself to pick up new packets.
\n" + << "
\n" + << " \n" + << " \n" + << " \n" + << " \n" + << " \n" + << " \n" + << " Wheel: X zoom, Shift+wheel: Y zoom, double-click: reset\n" + << "
\n" << " \n" << "
\n" << " CH1\n" @@ -179,10 +195,109 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " 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" - << " function drawAxes(minY, maxY, maxT) {\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" @@ -198,7 +313,7 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " 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 = maxT * 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" @@ -206,13 +321,13 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " }\n" << " return { left, top, width, height };\n" << " }\n" - << " function drawTrace(points, color, box, minY, maxY, maxT) {\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, maxT);\n" + << " const spanT = Math.max(1e-9, xMax - xMin);\n" << " ctx.beginPath();\n" << " points.forEach((p, i) => {\n" - << " const x = box.left + (p[0] / spanT) * box.width;\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" @@ -222,8 +337,10 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " packetInfo.textContent = 'Waiting for packets...';\n" << " timingInfo.textContent = '';\n" << " zeroInfo.textContent = '';\n" + << " zoomInfo.textContent = 'View: auto';\n" << " packetRateInfo.textContent = '';\n" << " legendCh2.style.display = '';\n" + << " refreshAutoCheckbox();\n" << " updateInfo.textContent = 'Auto-refresh every 500 ms';\n" << " statusText.textContent = message;\n" << " ctx.clearRect(0, 0, canvas.width, canvas.height);\n" @@ -231,6 +348,7 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " 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 hasCh2 = (data.channelCount || 2) > 1 && ch2.length > 0;\n" @@ -246,17 +364,47 @@ void write_live_html_document(const std::string& path, const std::string& data_j << " minY -= pad; maxY += pad;\n" << " }\n" << " const maxT = Math.max(data.durationMs / 1000.0, 1e-9);\n" - << " const box = drawAxes(minY, maxY, maxT);\n" - << " drawTrace(ch1, '#005bbb', box, minY, maxY, maxT);\n" - << " if (hasCh2) drawTrace(ch2, '#d62828', box, minY, maxY, maxT);\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" << " legendCh2.style.display = hasCh2 ? '' : '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 = '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 reloads itself every 500 ms.';\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" << " if (!liveData || liveData.status === 'waiting') {\n" << " renderWaiting((liveData && liveData.message) ? liveData.message : 'Waiting for first packet...');\n" << " } else {\n" diff --git a/main.cpp b/main.cpp index 97e6946..59edf67 100644 --- a/main.cpp +++ b/main.cpp @@ -371,6 +371,7 @@ void print_help(const char* exe_name) { << "inside the same input stream, packets are split continuously by DI_SYN2 edges, and if\n" << "digital input 1 changes state the corresponding ADC sample is written to the buffer as 0.\n" << "Open live_plot.html in a browser to see the live graph update over time.\n" + << "The live HTML supports X/Y zoom buttons, mouse-wheel zoom, and reset.\n" << "\n" << "Recommended working example:\n" << " " << exe_name diff --git a/main.exe b/main.exe index c825b14..aa1a708 100644 Binary files a/main.exe and b/main.exe differ