Add live plot zoom controls

This commit is contained in:
kamil
2026-04-09 00:23:16 +03:00
parent a1e7afc8a7
commit b5a76d1f7c
3 changed files with 157 additions and 8 deletions

View File

@ -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" << " .title { font-size:22px; font-weight:600; }\n"
<< " .meta { color:var(--muted); font-size:14px; display:flex; gap:18px; flex-wrap:wrap; }\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" << " .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" << " 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" << " .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" << " .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
<< " <span id=\"timingInfo\"></span>\n" << " <span id=\"timingInfo\"></span>\n"
<< " <span id=\"packetRateInfo\"></span>\n" << " <span id=\"packetRateInfo\"></span>\n"
<< " <span id=\"zeroInfo\"></span>\n" << " <span id=\"zeroInfo\"></span>\n"
<< " <span id=\"zoomInfo\"></span>\n"
<< " </div>\n" << " </div>\n"
<< " </div>\n" << " </div>\n"
<< " <div class=\"meta\">\n" << " <div class=\"meta\">\n"
@ -164,6 +171,15 @@ void write_live_html_document(const std::string& path, const std::string& data_j
<< " </div>\n" << " </div>\n"
<< " </div>\n" << " </div>\n"
<< " <div class=\"status\" id=\"statusText\">Open this page once and leave it open. It reloads itself to pick up new packets.</div>\n" << " <div class=\"status\" id=\"statusText\">Open this page once and leave it open. It reloads itself 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" << " <canvas id=\"plot\" width=\"1200\" height=\"620\"></canvas>\n"
<< " <div class=\"legend\">\n" << " <div class=\"legend\">\n"
<< " <span id=\"legendCh1\"><span class=\"sw\" style=\"background:#005bbb\"></span>CH1</span>\n" << " <span id=\"legendCh1\"><span class=\"sw\" style=\"background:#005bbb\"></span>CH1</span>\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 packetInfo = document.getElementById('packetInfo');\n"
<< " const timingInfo = document.getElementById('timingInfo');\n" << " const timingInfo = document.getElementById('timingInfo');\n"
<< " const zeroInfo = document.getElementById('zeroInfo');\n" << " const zeroInfo = document.getElementById('zeroInfo');\n"
<< " const zoomInfo = document.getElementById('zoomInfo');\n"
<< " const packetRateInfo = document.getElementById('packetRateInfo');\n" << " const packetRateInfo = document.getElementById('packetRateInfo');\n"
<< " const updateInfo = document.getElementById('updateInfo');\n" << " const updateInfo = document.getElementById('updateInfo');\n"
<< " const legendCh2 = document.getElementById('legendCh2');\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" << " const left = 78, top = 24, width = canvas.width - 112, height = canvas.height - 92;\n"
<< " ctx.clearRect(0, 0, canvas.width, canvas.height);\n" << " ctx.clearRect(0, 0, canvas.width, canvas.height);\n"
<< " ctx.fillStyle = '#fbfdff'; ctx.fillRect(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" << " ctx.fillStyle = '#607080'; ctx.font = '12px Segoe UI';\n"
<< " for (let i = 0; i <= 10; i += 1) {\n" << " for (let i = 0; i <= 10; i += 1) {\n"
<< " const x = left + width * i / 10;\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" << " ctx.fillText(t.toFixed(6), x - 18, top + height + 22);\n"
<< " const y = top + height - height * i / 10;\n" << " const y = top + height - height * i / 10;\n"
<< " const v = minY + (maxY - minY) * 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" << " }\n"
<< " return { left, top, width, height };\n" << " return { left, top, width, height };\n"
<< " }\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" << " if (!points || points.length === 0) return;\n"
<< " const spanY = Math.max(1e-9, maxY - minY);\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" << " ctx.beginPath();\n"
<< " points.forEach((p, i) => {\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" << " 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" << " if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);\n"
<< " });\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" << " packetInfo.textContent = 'Waiting for packets...';\n"
<< " timingInfo.textContent = '';\n" << " timingInfo.textContent = '';\n"
<< " zeroInfo.textContent = '';\n" << " zeroInfo.textContent = '';\n"
<< " zoomInfo.textContent = 'View: auto';\n"
<< " packetRateInfo.textContent = '';\n" << " packetRateInfo.textContent = '';\n"
<< " legendCh2.style.display = '';\n" << " legendCh2.style.display = '';\n"
<< " refreshAutoCheckbox();\n"
<< " updateInfo.textContent = 'Auto-refresh every 500 ms';\n" << " updateInfo.textContent = 'Auto-refresh every 500 ms';\n"
<< " statusText.textContent = message;\n" << " statusText.textContent = message;\n"
<< " ctx.clearRect(0, 0, canvas.width, canvas.height);\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" << " ctx.fillStyle = '#607080'; ctx.font = '20px Segoe UI'; ctx.fillText('Waiting for first packet...', 32, 80);\n"
<< " }\n" << " }\n"
<< " function renderPacket(data) {\n" << " function renderPacket(data) {\n"
<< " latestPacket = data;\n"
<< " const ch1 = data.ch1 || [];\n" << " const ch1 = data.ch1 || [];\n"
<< " const ch2 = data.ch2 || [];\n" << " const ch2 = data.ch2 || [];\n"
<< " const hasCh2 = (data.channelCount || 2) > 1 && ch2.length > 0;\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" << " minY -= pad; maxY += pad;\n"
<< " }\n" << " }\n"
<< " const maxT = Math.max(data.durationMs / 1000.0, 1e-9);\n" << " const maxT = Math.max(data.durationMs / 1000.0, 1e-9);\n"
<< " const box = drawAxes(minY, maxY, maxT);\n" << " latestAutoBounds = { minY, maxY, maxT };\n"
<< " drawTrace(ch1, '#005bbb', box, minY, maxY, maxT);\n" << " const view = currentViewBounds(latestAutoBounds);\n"
<< " if (hasCh2) drawTrace(ch2, '#d62828', box, minY, maxY, maxT);\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" << " legendCh2.style.display = hasCh2 ? '' : 'none';\n"
<< " refreshAutoCheckbox();\n"
<< " packetInfo.textContent = 'Packet #' + data.packetIndex + ' of ' + data.packetsSeen + ', channels: ' + (data.channelCount || 2);\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" << " timingInfo.textContent = 'Frames/ch: ' + data.framesPerChannel + ', duration: ' + data.durationMs.toFixed(3) + ' ms';\n"
<< " packetRateInfo.textContent = 'Packets/s: ' + data.packetsPerSecond.toFixed(3);\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" << " 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" << " updateInfo.textContent = 'Snapshot: ' + data.updatedAt;\n"
<< " statusText.textContent = 'Close reason: ' + data.closeReason + '. This page reloads itself every 500 ms.';\n" << " statusText.textContent = 'Close reason: ' + data.closeReason + '. This page reloads itself every 500 ms.';\n"
<< " }\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" << " if (!liveData || liveData.status === 'waiting') {\n"
<< " renderWaiting((liveData && liveData.message) ? liveData.message : 'Waiting for first packet...');\n" << " renderWaiting((liveData && liveData.message) ? liveData.message : 'Waiting for first packet...');\n"
<< " } else {\n" << " } else {\n"

View File

@ -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" << "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" << "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" << "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" << "\n"
<< "Recommended working example:\n" << "Recommended working example:\n"
<< " " << exe_name << " " << exe_name

BIN
main.exe

Binary file not shown.