} newItems - New items to add
*/
function pushToBuffer(newItems) {
if (newItems.length >= countPoints) {
dataBuffer = Array.from(newItems.slice(-countPoints));
} else {
dataBuffer.splice(0, newItems.length);
dataBuffer.push(...newItems);
}
}
/*
* ==========================================
* 5. Visualization & Rendering
* ==========================================
*/
/**
* Draw the grid and axis labels
* @param {number} w - Canvas width
* @param {number} h - Canvas height
*/
function drawGrid(w, h) {
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.fillStyle = '#fff';
ctx.font = '15px monospace';
// Helper to draw text with lozenge background
const drawLabel = (text, x, y, align) => {
ctx.save();
const paddingX = 6;
const paddingY = 3;
const fontSize = 13;
// Set font to measure correctly
ctx.font = `${fontSize}px monospace`;
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
// Calculate box position
const boxHeight = fontSize + paddingY * 2;
const boxWidth = textWidth + paddingX * 2;
let boxX;
if (align === 'left') boxX = x;
else if (align === 'center') boxX = x - textWidth / 2;
else if (align === 'right') boxX = x - textWidth;
// Adjust for padding and visual centering
boxX -= paddingX;
const boxY = (y - 4) - boxHeight / 2;
// Draw semi-transparent lozenge
ctx.fillStyle = 'rgba(255, 255, 255, 0.55)';
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
ctx.fill();
// Draw text
ctx.fillStyle = 'black';
ctx.textAlign = align;
ctx.fillText(text, x, y);
ctx.restore();
};
// Determine Visible Voltage Range
const minV = YtoVolts(h);
const maxV = YtoVolts(0);
// Calculate handy ticks in the visible range
const ticks = calculateNiceTicks(minV, maxV, 8);
for (let val of ticks) {
const y = VoltsToY(val);
// Skip if out of bounds (with a bit of margin)
if (y < -20 || y > h + 20) continue;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
drawLabel(val.toFixed(2) + 'V', 5, y + 4, 'left');
}
// Determine Visible Time Range
const minT = XtoTime(0);
const maxT = XtoTime(w);
const tTicks = calculateNiceTicks(minT, maxT, 8);
for (let t of tTicks) {
const x = TimeToX(t);
if (x < -50 || x > w + 50) continue;
let timeStr;
if (Math.abs(t) >= 1000) {
timeStr = (t / 1000).toFixed(2) + 's';
} else {
timeStr = t.toFixed(1) + 'ms';
}
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
drawLabel(timeStr, x, h - 5, 'center');
}
}
/**
* Draw crosshairs at specific position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {string} color - Color string
*/
function drawCrosshairs(x, y, color) {
ctx.setLineDash([5, 5]);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
// Draw vertical line
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
// Draw horizontal line
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
ctx.setLineDash([]);
}
/**
* Main draw loop
*/
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const maxAdcVal = 4096; // 12-bit fixed scale
// Trigger values
let drawIdx = dataBuffer.length - w;
if (drawIdx < 0)
drawIdx = 0;
else {
const triggerVal = (4096 - (parseInt(triggerLevel.value) || 2048));
// Helper to extract value for trigger (handles numbers and avg objects)
const getVal = (i) => {
const v = dataBuffer[i];
return (typeof v === 'object' && v !== null) ? v.avg : v;
};
if (triggerLevel.invert) {
while (drawIdx >= 0) {
// Look for falling edge
if (getVal(drawIdx) < triggerVal && getVal(drawIdx + 1) > triggerVal) {
break;
}
drawIdx -= 1;
}
} else {
while (drawIdx >= 0) {
// Look for rising edge
if (getVal(drawIdx) > triggerVal && getVal(drawIdx + 1) < triggerVal) {
break;
}
drawIdx -= 1;
}
}
const triggerVolts = ((maxAdcVal - activeConfig.trigger) * getMaxVoltage() / maxAdcVal).toFixed(2) + "V";
const triggerDir = triggerLevel.invert ? '🠉' : '🠋';
if (drawIdx < 0) {
drawIdx = dataBuffer.length - w;
triggerStatusEl.innerHTML = `${triggerVolts} ${triggerDir} No trigger`;
} else {
triggerStatusEl.innerHTML = `${triggerVolts} ${triggerDir} Triggered`;
}
}
// Pass 1: Draw Min/Max ranges for downsampled data
ctx.lineWidth = 1;
ctx.strokeStyle = '#2b7044'; // Dark green
ctx.beginPath();
for (let i = 0; i < w; i++) {
const val = dataBuffer[i + drawIdx];
if (typeof val === 'object' && val !== null) {
const sx = i * viewTransform.scale + viewTransform.offsetX;
const rawYMin = h - (val.min / maxAdcVal * h);
const rawYMax = h - (val.max / maxAdcVal * h);
const screenYMin = rawYMin * viewTransform.scale + viewTransform.offsetY;
const screenYMax = rawYMax * viewTransform.scale + viewTransform.offsetY;
ctx.moveTo(sx, screenYMin);
ctx.lineTo(sx, screenYMax);
}
}
ctx.stroke();
// Pass 2: Draw Main Trace (Avg or raw value)
ctx.lineWidth = 2;
ctx.strokeStyle = '#4ade80'; // Bright green
ctx.beginPath();
for (let i = 0; i < w; i++) {
const val = dataBuffer[i + drawIdx];
const sx = i * viewTransform.scale + viewTransform.offsetX;
/** @type {number} */ let rawVal;
if (typeof val === 'object' && val !== null) {
rawVal = val.avg;
} else {
rawVal = val;
}
const yp = h - (rawVal / maxAdcVal * h);
const sy = yp * viewTransform.scale + viewTransform.offsetY;
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// Draw Background Grid
drawGrid(w, h);
// Draw Crosshairs if mouse is over the canvas
if (lastMousePosition.x !== null && lastMousePosition.y !== null) {
drawCrosshairs(lastMousePosition.x, lastMousePosition.y, '#4ade80');
}
// Draw reference crosshairs and deltas if frozen
if (isFrozen && referencePosition) {
// Convert World Reference to Screen
const refX = TimeToX(referencePosition.t);
const refY = VoltsToY(referencePosition.v);
drawCrosshairs(refX, refY, '#eab308');
}
}
/**
* Loop the animation
*/
function animationLoop() {
if (!isFrozen) {
draw();
}
requestAnimationFrame(animationLoop);
}
/*
* ==========================================
* 6. User Interaction
* ==========================================
*/
/**
* Update background color of trigger level slider
*/
function triggerColor() {
activeConfig.trigger = parseInt(triggerLevel.value);
const value = (activeConfig.trigger - parseInt(triggerLevel.min)) / (parseInt(triggerLevel.max) - parseInt(triggerLevel.min)) * 100;
triggerLevel.style.background = triggerLevel.invert
? `linear-gradient(to bottom, #00000070 0%, #ff020270 ${value}%, #1302ff70 ${value}%, #00000070 100%)`
: `linear-gradient(to bottom, #00000070 0%, #1302ff70 ${value}%, #ff020270 ${value}%, #00000070 100%)`
}
/**
* Update Mouse info
* @param {MouseEvent | {offsetX: number, offsetY: number, pageX: number, pageY: number}} event
*/
function updateInfo(event) {
// Use raw coordinates or event helpers
const voltage = YtoVolts(event.offsetY);
const timeOffset = XtoTime(event.offsetX);
let info = `${voltage.toFixed(3)}V, ${timeOffset.toFixed(2)}ms
`;
// Store the last mouse position
lastMousePosition.x = event.offsetX;
lastMousePosition.y = event.offsetY;
// Update delta panel position and content if frozen
if (isFrozen && referencePosition) {
const deltaVoltage = Math.abs(referencePosition.v - voltage);
const deltaTime = Math.abs(referencePosition.t - timeOffset);
// avoid divide by zero
const freq = deltaTime > 0.00001 ? (1000 / deltaTime).toFixed(2) : '---';
info += `ΔV ${deltaVoltage.toFixed(3)}V, ΔT ${deltaTime.toFixed(2)}ms (${freq} Hz)
`;
}
deltaPanel.style.left = `${event.pageX + 10}px`;
deltaPanel.style.top = `${event.pageY + 10}px`;
deltaPanel.innerHTML = info;
// Force redraw when frozen
if (isFrozen) {
draw();
}
}
// Trigger Level Events
triggerLevel.addEventListener('input', triggerColor);
triggerLevel.addEventListener('mousedown', function () {
this.downValue = this.value;
});
triggerLevel.addEventListener('mouseup', function () {
if (this.downValue == this.value) {
this.invert = !this.invert;
activeConfig.invert = this.invert;
triggerColor();
this.dispatchEvent(new Event("change"));
}
delete this.downValue;
});
// Canvas Interaction Events
canvas.addEventListener('click', (event) => {
isFrozen = !isFrozen;
if (isFrozen) {
// Set reference position in World Coordinates
referencePosition = {
t: XtoTime(event.offsetX),
v: YtoVolts(event.offsetY)
};
}
});
canvas.addEventListener('mousemove', updateInfo);
canvas.addEventListener('mouseenter', () => deltaPanel.style.display = 'block');
canvas.addEventListener('mouseleave', () => deltaPanel.style.display = 'none');
canvas.addEventListener('wheel', function (e) {
e.preventDefault();
const zoomFactor = 1.1;
const direction = e.deltaY < 0 ? 1 : -1;
const factor = direction > 0 ? zoomFactor : 1 / zoomFactor;
let newScale = viewTransform.scale * factor;
if (newScale < 1.001) {
// Snap to 100% and reset position
newScale = 1.0;
viewTransform.offsetX = 0;
viewTransform.offsetY = 0;
} else if (newScale > 50) {
return; // Max limit
} else {
// Zoom centered on mouse
const mx = e.offsetX;
const my = e.offsetY;
viewTransform.offsetX = mx - (mx - viewTransform.offsetX) * factor;
viewTransform.offsetY = my - (my - viewTransform.offsetY) * factor;
}
viewTransform.scale = newScale;
if (newScale < 1.001) {
statusEl.textContent = 'Connected via WebSocket';
} else {
statusEl.textContent = 'Scaled to ' + newScale.toFixed(2) + 'x';
}
draw();
if (isFrozen) {
updateInfo({ offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY });
}
});
/*
* ==========================================
* 7. Network & Configuration Management
* ==========================================
*/
/**
* Connect to WebSocket
*/
function connect() {
loadStoredConfig();
clearTimeout(reconnectTimeout);
if (reconnectBtn) reconnectBtn.style.display = 'none';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/signal`;
// For local testing without ESP hardware, uncomment next line:
// const wsUrl = 'ws://localhost:8080/signal';
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
statusEl.textContent = 'Connected via WebSocket';
statusEl.style.color = '#4ade80';
if (ws.readyState === WebSocket.OPEN) ws.send("hello");
};
ws.onclose = () => {
scheduleReconnect();
};
ws.onmessage = (event) => {
try {
const arr = new Uint16Array(event.data);
if (arr?.length) {
processData(arr);
}
} catch (e) {
console.error('Parse error:', e);
}
};
}
/**
* Schedule a reconnect attempt
*/
function scheduleReconnect() {
statusEl.textContent = 'Disconnected. Retrying in 2s...';
statusEl.style.color = '#ef4444';
reconnectTimeout = window.setTimeout(connect, 2000); // Use window.setTimeout explicit
}
/**
* Send configuration to ESP32
*/
function setParams() {
const desiredRate = parseInt(sampleRateSelect.value);
const hardwareRate = desiredRate < 1000 ? 1000 : desiredRate;
const payload = {
sample_rate: hardwareRate,
bit_width: parseInt(bitWidthSelect.value),
atten: parseInt(attenSelect.value),
test_hz: parseInt(testHzSelect.value)
};
fetch('/params', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => {
if (res.ok) {
lowRateState.accMin = 4096;
lowRateState.accMax = 0;
lowRateState.accSum = 0;
lowRateState.accCount = 0;
// Update active config
activeConfig = { ...payload, desiredRate, trigger: parseInt(triggerLevel.value) || 2048, invert: triggerLevel.invert };
// Save to localStorage
localStorage.setItem('esp32_adc_config', JSON.stringify(activeConfig));
} else {
alert('Error updating configuration');
}
}).catch(err => alert('Network error: ' + err));
}
/**
* Load configuration from LocalStorage
*/
function loadStoredConfig() {
const stored = localStorage.getItem('esp32_adc_config');
if (stored) {
try {
const cfg = JSON.parse(stored);
if (cfg.desiredRate) sampleRateSelect.value = cfg.desiredRate;
if (cfg.bit_width) bitWidthSelect.value = cfg.bit_width;
if (cfg.atten !== undefined) attenSelect.value = cfg.atten;
if (cfg.test_hz) testHzSelect.value = cfg.test_hz;
if (cfg.invert) triggerLevel.invert = Boolean(cfg.invert);
if (cfg.trigger) triggerLevel.value = String(cfg.trigger);
triggerColor();
setParams();
} catch (e) {
console.error("Failed to load config", e);
}
}
}
// Config Listeners
if (reconnectBtn) reconnectBtn.addEventListener('click', connect);
[sampleRateSelect, bitWidthSelect, attenSelect, testHzSelect].forEach(input => {
if (input) input.addEventListener('change', setParams)
});
triggerLevel.addEventListener('change', () => localStorage.setItem('esp32_adc_config', JSON.stringify(activeConfig)));
if (resetBtn) resetBtn.addEventListener('click', () => {
localStorage.clear();
window.location.reload();
});
if (powerOffBtn) powerOffBtn.addEventListener('click', () => window.location.href = "/poweroff");
/**
* Setup WiFi modal listeners
*/
function setupWifiListeners() {
if (wifiBtn) wifiBtn.onclick = () => {
wifiModal.style.display = "flex";
wifiSsidInput.focus();
};
if (closeWifiBtn) closeWifiBtn.onclick = () => wifiModal.style.display = "none";
if (saveWifiBtn) saveWifiBtn.onclick = () => {
const ssid = wifiSsidInput.value;
const pass = wifiPassInput.value;
if (!ssid) {
alert("SSID is required");
return;
}
saveWifiBtn.innerText = "Saving...";
fetch('/api/save_wifi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password: pass })
})
.then(res => res.text())
.then(text => {
alert(text);
window.location.reload();
})
.catch(err => {
alert("Error: " + err);
saveWifiBtn.innerText = "Save";
});
};
}
/*
* ==========================================
* 8. Initialization
* ==========================================
*/
/**
* Resize canvas to fit container
*/
function resize() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
window.addEventListener('resize', resize);
// Startup sequence
resize();
setupWifiListeners();
loadStoredConfig();
connect();
requestAnimationFrame(animationLoop);