Files
kamil_adc/live_plot.html
2026-04-09 12:04:50 +03:00

333 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>E-502 Live Plot</title>
<style>
:root { color-scheme: light; --bg:#f4f7fb; --panel:#ffffff; --ink:#1f2d3d; --muted:#607080; --grid:#d9e2ec; --blue:#005bbb; --red:#d62828; --green:#1b8f3a; --orange:#ee8a12; }
body { margin:0; font-family:"Segoe UI", Arial, sans-serif; background:linear-gradient(180deg,#eef3f8 0%,#f8fbfd 100%); color:var(--ink); }
.wrap { max-width:1200px; margin:0 auto; padding:24px; }
.panel { background:var(--panel); border:1px solid #dbe4ed; border-radius:16px; box-shadow:0 10px 30px rgba(23,43,77,0.08); overflow:hidden; }
.head { padding:18px 22px; border-bottom:1px solid #e6edf4; display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }
.title { font-size:22px; font-weight:600; }
.meta { color:var(--muted); font-size:14px; display:flex; gap:18px; flex-wrap:wrap; }
.status { padding:14px 22px 0 22px; color:var(--muted); font-size:14px; }
.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:12px 22px 0 22px; color:var(--muted); font-size:13px; }
.controls button { border:1px solid #cfd8e3; background:#ffffff; color:#203040; border-radius:10px; padding:7px 12px; font:inherit; cursor:pointer; }
.controls button:hover { background:#f5f8fb; }
.controls label { display:flex; align-items:center; gap:6px; }
.controls input { accent-color:#005bbb; }
.hint { color:#7a8794; }
canvas { display:block; width:100%; height:620px; background:#fbfdff; }
.legend { display:flex; gap:22px; padding:10px 22px 20px 22px; color:var(--muted); font-size:14px; }
.sw { display:inline-block; width:28px; height:3px; border-radius:2px; margin-right:8px; vertical-align:middle; }
</style>
</head>
<body>
<div class="wrap">
<div class="panel">
<div class="head">
<div>
<div class="title">E-502 Live Packet Plot</div>
<div class="meta">
<span id="packetInfo">Waiting for packets...</span>
<span id="timingInfo"></span>
<span id="packetRateInfo"></span>
<span id="zeroInfo"></span>
<span id="zoomInfo"></span>
</div>
</div>
<div class="meta">
<span id="updateInfo">Auto-refresh every 500 ms</span>
</div>
</div>
<div class="status" id="statusText">Open this page once and leave it open. It refreshes its data script to pick up new packets.</div>
<div class="controls">
<button type="button" id="zoomXIn">X+</button>
<button type="button" id="zoomXOut">X-</button>
<button type="button" id="zoomYIn">Y+</button>
<button type="button" id="zoomYOut">Y-</button>
<button type="button" id="resetZoom">Reset</button>
<label><input type="checkbox" id="autoZoom" checked/>Auto view</label>
<span class="hint">Wheel: X zoom, Shift+wheel: Y zoom, double-click: reset</span>
</div>
<canvas id="plot" width="1200" height="620"></canvas>
<div class="legend">
<span id="legendCh1"><span class="sw" style="background:#005bbb"></span>CH1</span>
<span id="legendCh2"><span class="sw" style="background:#d62828"></span>CH2</span>
<span id="legendDi1"><span class="sw" style="background:#1b8f3a"></span>DI1</span>
</div>
</div>
</div>
<script>
const dataScriptUrl = 'live_plot.js';
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const statusText = document.getElementById('statusText');
const packetInfo = document.getElementById('packetInfo');
const timingInfo = document.getElementById('timingInfo');
const zeroInfo = document.getElementById('zeroInfo');
const zoomInfo = document.getElementById('zoomInfo');
const packetRateInfo = document.getElementById('packetRateInfo');
const updateInfo = document.getElementById('updateInfo');
const legendCh2 = document.getElementById('legendCh2');
const legendDi1 = document.getElementById('legendDi1');
const zoomXIn = document.getElementById('zoomXIn');
const zoomXOut = document.getElementById('zoomXOut');
const zoomYIn = document.getElementById('zoomYIn');
const zoomYOut = document.getElementById('zoomYOut');
const resetZoom = document.getElementById('resetZoom');
const autoZoom = document.getElementById('autoZoom');
const viewStorageKey = 'e502_live_plot_view_v1';
let latestPacket = null;
let latestAutoBounds = null;
function defaultViewState() {
return { auto: true, xMin: null, xMax: null, yMin: null, yMax: null };
}
function loadViewState() {
try {
const raw = window.localStorage.getItem(viewStorageKey);
if (!raw) return defaultViewState();
const parsed = JSON.parse(raw);
return {
auto: parsed.auto !== false,
xMin: Number.isFinite(parsed.xMin) ? parsed.xMin : null,
xMax: Number.isFinite(parsed.xMax) ? parsed.xMax : null,
yMin: Number.isFinite(parsed.yMin) ? parsed.yMin : null,
yMax: Number.isFinite(parsed.yMax) ? parsed.yMax : null,
};
} catch (err) {
return defaultViewState();
}
}
let viewState = loadViewState();
function saveViewState() {
try { window.localStorage.setItem(viewStorageKey, JSON.stringify(viewState)); } catch (err) {}
}
function clampNumber(value, min, max, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function currentViewBounds(bounds) {
if (!bounds || viewState.auto) {
return { xMin: 0, xMax: bounds ? bounds.maxT : 1, yMin: bounds ? bounds.minY : -1, yMax: bounds ? bounds.maxY : 1 };
}
const totalX = Math.max(1e-9, bounds.maxT);
const xMin = clampNumber(viewState.xMin, 0, totalX, 0);
const xMax = clampNumber(viewState.xMax, xMin + 1e-9, totalX, totalX);
const yPad = Math.max(0.05, (bounds.maxY - bounds.minY) * 4.0);
const yLimitMin = bounds.minY - yPad;
const yLimitMax = bounds.maxY + yPad;
const yMin = clampNumber(viewState.yMin, yLimitMin, yLimitMax - 1e-9, bounds.minY);
const yMax = clampNumber(viewState.yMax, yMin + 1e-9, yLimitMax, bounds.maxY);
return { xMin, xMax, yMin, yMax };
}
function zoomRange(min, max, factor, limitMin, limitMax) {
const totalSpan = Math.max(1e-9, limitMax - limitMin);
const minSpan = totalSpan / 1000.0;
let span = (max - min) * factor;
span = Math.max(minSpan, Math.min(totalSpan, span));
const center = (min + max) / 2.0;
let newMin = center - span / 2.0;
let newMax = center + span / 2.0;
if (newMin < limitMin) {
newMax += (limitMin - newMin);
newMin = limitMin;
}
if (newMax > limitMax) {
newMin -= (newMax - limitMax);
newMax = limitMax;
}
newMin = Math.max(limitMin, newMin);
newMax = Math.min(limitMax, newMax);
return { min: newMin, max: newMax };
}
function refreshAutoCheckbox() {
autoZoom.checked = !!viewState.auto;
}
function applyZoomX(factor) {
if (!latestAutoBounds || !latestPacket) return;
const current = currentViewBounds(latestAutoBounds);
const range = zoomRange(current.xMin, current.xMax, factor, 0, Math.max(1e-9, latestAutoBounds.maxT));
viewState = { auto: false, xMin: range.min, xMax: range.max, yMin: current.yMin, yMax: current.yMax };
saveViewState();
refreshAutoCheckbox();
renderPacket(latestPacket);
}
function applyZoomY(factor) {
if (!latestAutoBounds || !latestPacket) return;
const current = currentViewBounds(latestAutoBounds);
const yPad = Math.max(0.05, (latestAutoBounds.maxY - latestAutoBounds.minY) * 4.0);
const range = zoomRange(current.yMin, current.yMax, factor, latestAutoBounds.minY - yPad, latestAutoBounds.maxY + yPad);
viewState = { auto: false, xMin: current.xMin, xMax: current.xMax, yMin: range.min, yMax: range.max };
saveViewState();
refreshAutoCheckbox();
renderPacket(latestPacket);
}
function resetView() {
viewState = defaultViewState();
saveViewState();
refreshAutoCheckbox();
if (latestPacket) renderPacket(latestPacket);
}
function drawAxes(minY, maxY, xMin, xMax) {
const left = 78, top = 24, width = canvas.width - 112, height = canvas.height - 92;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fbfdff'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff'; ctx.fillRect(left, top, width, height);
ctx.strokeStyle = '#dbe4ed'; ctx.lineWidth = 1; ctx.strokeRect(left, top, width, height);
ctx.strokeStyle = '#edf2f7';
for (let i = 0; i <= 10; i += 1) {
const x = left + width * i / 10;
const y = top + height * i / 10;
ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(left + width, y); ctx.stroke();
}
ctx.fillStyle = '#607080'; ctx.font = '12px Segoe UI';
for (let i = 0; i <= 10; i += 1) {
const x = left + width * i / 10;
const t = xMin + (xMax - xMin) * i / 10;
ctx.fillText(t.toFixed(6), x - 18, top + height + 22);
const y = top + height - height * i / 10;
const v = minY + (maxY - minY) * i / 10;
ctx.fillText(v.toFixed(3), 8, y + 4);
}
return { left, top, width, height };
}
function drawTrace(points, color, box, minY, maxY, xMin, xMax) {
if (!points || points.length === 0) return;
const spanY = Math.max(1e-9, maxY - minY);
const spanT = Math.max(1e-9, xMax - xMin);
ctx.beginPath();
points.forEach((p, i) => {
const x = box.left + ((p[0] - xMin) / spanT) * box.width;
const y = box.top + box.height - ((p[1] - minY) / spanY) * box.height;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = color; ctx.lineWidth = 1.25; ctx.stroke();
}
function drawDigitalTrace(points, box, xMin, xMax) {
if (!points || points.length === 0) return;
const spanT = Math.max(1e-9, xMax - xMin);
const band = { left: box.left, top: box.top + 10, width: box.width, height: 44 };
ctx.fillStyle = 'rgba(27,143,58,0.06)'; ctx.fillRect(band.left, band.top, band.width, band.height);
ctx.strokeStyle = 'rgba(27,143,58,0.20)'; ctx.strokeRect(band.left, band.top, band.width, band.height);
ctx.fillStyle = '#1b8f3a'; ctx.font = '12px Segoe UI'; ctx.fillText('DI1', band.left + 6, band.top + 14);
ctx.beginPath();
points.forEach((p, i) => {
const x = band.left + ((p[0] - xMin) / spanT) * band.width;
const y = band.top + band.height - ((0.15 + 0.7 * p[1]) * band.height);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#1b8f3a'; ctx.lineWidth = 1.4; ctx.stroke();
}
function renderWaiting(message) {
packetInfo.textContent = 'Waiting for packets...';
timingInfo.textContent = '';
zeroInfo.textContent = '';
zoomInfo.textContent = 'View: auto';
packetRateInfo.textContent = '';
legendCh2.style.display = '';
legendDi1.style.display = 'none';
refreshAutoCheckbox();
updateInfo.textContent = 'Polling every 500 ms';
statusText.textContent = message;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fbfdff'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#607080'; ctx.font = '20px Segoe UI'; ctx.fillText('Waiting for first packet...', 32, 80);
}
function renderPacket(data) {
latestPacket = data;
const ch1 = data.ch1 || [];
const ch2 = data.ch2 || [];
const di1 = data.di1 || [];
const hasCh2 = (data.channelCount || 2) > 1 && ch2.length > 0;
const hasDi1Trace = !!data.hasDi1Trace && di1.length > 0;
const values = [];
ch1.forEach(p => values.push(p[1]));
if (hasCh2) ch2.forEach(p => values.push(p[1]));
let minY = Math.min(...values);
let maxY = Math.max(...values);
if (!Number.isFinite(minY) || !Number.isFinite(maxY) || minY === maxY) {
minY = -1; maxY = 1;
} else {
const pad = Math.max(0.05, (maxY - minY) * 0.08);
minY -= pad; maxY += pad;
}
const maxT = Math.max(data.durationMs / 1000.0, 1e-9);
latestAutoBounds = { minY, maxY, maxT };
const view = currentViewBounds(latestAutoBounds);
const box = drawAxes(view.yMin, view.yMax, view.xMin, view.xMax);
drawTrace(ch1, '#005bbb', box, view.yMin, view.yMax, view.xMin, view.xMax);
if (hasCh2) drawTrace(ch2, '#d62828', box, view.yMin, view.yMax, view.xMin, view.xMax);
if (hasDi1Trace) drawDigitalTrace(di1, box, view.xMin, view.xMax);
legendCh2.style.display = hasCh2 ? '' : 'none';
legendDi1.style.display = hasDi1Trace ? '' : 'none';
refreshAutoCheckbox();
packetInfo.textContent = 'Packet #' + data.packetIndex + ' of ' + data.packetsSeen + ', channels: ' + (data.channelCount || 2);
timingInfo.textContent = 'Frames/ch: ' + data.framesPerChannel + ', duration: ' + data.durationMs.toFixed(3) + ' ms';
packetRateInfo.textContent = 'Packets/s: ' + data.packetsPerSecond.toFixed(3);
zeroInfo.textContent = hasDi1Trace
? ('DI1 trace: ' + data.di1Frames + ' frame samples')
: ('Zeroed on DI1 change: ' + data.zeroedPercent.toFixed(3) + '% (' + data.zeroedSamples + '/' + data.storedSamples + ')');
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');
updateInfo.textContent = 'Snapshot: ' + data.updatedAt;
statusText.textContent = 'Close reason: ' + data.closeReason + '. This page refreshes its data every 500 ms.';
}
function applyLiveData(data) {
if (!data || data.status === 'waiting') {
renderWaiting((data && data.message) ? data.message : 'Waiting for first packet...');
} else {
renderPacket(data);
}
}
function loadLatestData() {
const oldScript = document.getElementById('liveDataScript');
if (oldScript) oldScript.remove();
window.e502LiveData = null;
const script = document.createElement('script');
script.id = 'liveDataScript';
const sep = dataScriptUrl.includes('?') ? '&' : '?';
script.src = dataScriptUrl + sep + 'ts=' + Date.now();
script.onload = () => {
applyLiveData(window.e502LiveData);
};
script.onerror = () => {
statusText.textContent = 'Cannot load live data script: ' + dataScriptUrl;
};
document.head.appendChild(script);
}
zoomXIn.addEventListener('click', () => applyZoomX(0.5));
zoomXOut.addEventListener('click', () => applyZoomX(2.0));
zoomYIn.addEventListener('click', () => applyZoomY(0.5));
zoomYOut.addEventListener('click', () => applyZoomY(2.0));
resetZoom.addEventListener('click', () => resetView());
autoZoom.addEventListener('change', () => {
if (autoZoom.checked) {
resetView();
} else if (latestAutoBounds && latestPacket) {
const current = currentViewBounds(latestAutoBounds);
viewState = { auto: false, xMin: current.xMin, xMax: current.xMax, yMin: current.yMin, yMax: current.yMax };
saveViewState();
renderPacket(latestPacket);
}
});
canvas.addEventListener('wheel', (event) => {
if (!latestPacket) return;
event.preventDefault();
const factor = event.deltaY < 0 ? 0.8 : 1.25;
if (event.shiftKey) {
applyZoomY(factor);
} else {
applyZoomX(factor);
}
}, { passive: false });
canvas.addEventListener('dblclick', () => resetView());
renderWaiting('Waiting for first packet...');
loadLatestData();
window.setInterval(loadLatestData, 500);
</script>
</body>
</html>