canvas-oscilloscope
Version:
HTML5 Canvas oscilloscope simulator with responsive rendering, customizable waveforms, and real-time visualization capabilities.
534 lines (533 loc) • 28.9 kB
JavaScript
"use strict";
/**
* HTML5 Canvas-based oscilloscope simulator with responsive rendering,
* customizable waveforms, and real-time visualization capabilities.
*
* Features automatic viewport adjustment, debounced rendering optimization,
* and configurable grid/waveform styling through HTML attributes.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Oscilloscope = void 0;
class Oscilloscope {
static { this.DefaultPeakVoltage = 1000; } // 1000mV = 1V
static { this.DefaultPeriod = 1000; } // 1000ns = 1μs
static { this.MinCanvasSize = 10; }
static { this.PI2 = Math.PI * 2; }
/**
* A static private method to implement the debounce function. Debounce means that if the same event is triggered multiple times within a certain period, only the last operation will be executed.
* @typeParam T - The array type of the callback function parameters.
* @param callback - The callback function to be debounced. The `this` context of this function is an instance of the `Oscilloscope` class.
* @param delay - The debounce delay time, in milliseconds.
* @returns Returns a new function that will debounce the input callback function when called.
*/
static debounce(callback, delay) {
// Used to store the timer ID returned by setTimeout, initially undefined
let timeoutId;
// Used to store the arguments of the pending callback function
let pendingArgs;
return function (...args) {
// If new arguments are passed in, update pendingArgs; otherwise, keep it unchanged
// This ensures that the callback function is executed even if no arguments are passed in during the last call, such as a refresh due to a canvas size change.
args.length > 0 && (pendingArgs = args);
// If a timer has been set previously, clear it
if (timeoutId)
clearTimeout(timeoutId);
// Set a new timer to execute the callback function after the delay time
timeoutId = setTimeout(() => {
callback.apply(this, pendingArgs);
// After executing the callback function, set the timer ID to undefined
timeoutId = undefined;
}, delay);
};
}
/**
* Constructor of the Oscilloscope class, used to initialize an oscilloscope instance.
* @param canvasElementOrId - Can be an HTMLCanvasElement object or a string representing the ID of the canvas element.
* @param timeAxisDivisions - Number of divisions on the time axis, default is 14.
* @param amplitudeAxisDivisions - Number of divisions on the amplitude axis, default is 10.
*/
constructor(canvasElementOrId, timeAxisDivisions = 14, amplitudeAxisDivisions = 10) {
// Handle the canvas parameter: if it's a string, get the element by ID
let canvas;
if (typeof canvasElementOrId === 'string') {
const canvasElement = document.getElementById(canvasElementOrId);
// Validate that the obtained element is a valid canvas element
if (!canvasElement || !(canvasElement instanceof HTMLCanvasElement)) {
throw new Error('Invalid canvas ID');
}
canvas = canvasElement;
}
else {
// If a canvas element is passed in directly, use it
canvas = canvasElementOrId;
}
// Get color configuration from canvas element attributes, use default values if not set
const gridColor = canvas.getAttribute('grid-color') || '#e0e0e0';
const axisColor = canvas.getAttribute('axis-color') || '#607D8B';
const waveColor = canvas.getAttribute('wave-color') || '#FF0000';
const textColor = canvas.getAttribute('text-color') || '#455A64';
const rotation = canvas.getAttribute('rotation') || 'horizontal';
// Get the computed style of the canvas
const computedStyle = window.getComputedStyle(canvas);
const fontSize = computedStyle.fontSize;
const fontFamily = computedStyle.fontFamily;
const isVertical = rotation === 'vertical';
// Initialize configuration options
this.options = {
gridColor, // Grid line color
axisColor, // Axis color
waveColor, // Waveform color
textColor, // Text color
isVertical,
fontSize, // Font size
fontFamily, // Font family
padding: {
top: parseInt(computedStyle.paddingTop, 10) || 2,
left: parseInt(computedStyle.paddingLeft, 10) || 2,
right: parseInt(computedStyle.paddingRight, 10) || 2,
bottom: parseInt(computedStyle.paddingBottom, 10) || 40
},
timeAxisDivisions, // Number of time axis divisions
amplitudeAxisDivisions // Number of amplitude axis divisions
};
// Get the 2D drawing context
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for canvas');
}
if (isVertical) {
this.targetCanvas = canvas;
this.targetCtx = ctx;
this.canvas = document.createElement("canvas");
this.canvas.width = canvas.height;
this.canvas.height = canvas.width;
this.ctx = this.canvas.getContext("2d");
}
else {
this.canvas = canvas;
this.ctx = ctx;
}
// Wrap the drawing method with the debounce function, with a 1ms delay
this.drawWaveform = Oscilloscope.debounce(this.drawInternal, 1);
// Initialize the resize observer
this.initResizeObserver(canvas);
}
/**
* Draw the internal waveform of the oscilloscope.
* @param peakAmplitude Peak voltage (mV), default is 1000mV (1V).
* @param waveformPeriod Waveform period (ns), default is 1000ns (1μs).
* @param waveformSampler Optional waveform sampling function.
*/
drawInternal(peakAmplitude = Oscilloscope.DefaultPeakVoltage, waveformPeriod = Oscilloscope.DefaultPeriod, waveformSampler) {
// Check if the canvas area is valid
if (!this.clientArea ||
this.clientArea.clientHeight <= Oscilloscope.MinCanvasSize ||
this.clientArea.clientWidth <= Oscilloscope.MinCanvasSize) {
return;
}
// Parameter validation in development environment
if (process.env.NODE_ENV === 'development') {
if (peakAmplitude <= 0 || waveformPeriod <= 0) {
console.error(`Parameter error: Voltage=${peakAmplitude}mV, Period=${waveformPeriod}ns`);
return;
}
}
// Ensure parameters are valid, otherwise use default values
peakAmplitude = peakAmplitude > 0 ? peakAmplitude : Oscilloscope.DefaultPeakVoltage;
waveformPeriod = waveformPeriod > 0 ? waveformPeriod : Oscilloscope.DefaultPeriod;
// Destructure to get canvas size and center coordinates
const { clientWidth, clientHeight, centerX, centerY } = this.clientArea;
// Get the number of axis divisions from the configuration
const { timeAxisDivisions, amplitudeAxisDivisions } = this.options;
// Calculate the time axis and voltage axis ranges
const timeRange = waveformPeriod * 7; // Time range of 5 periods
const voltageRange = peakAmplitude * 2; // Range of ± peak voltage
// Calculate the pixel ratios and base values for the time axis and voltage axis
const { scale: nsPerPixel, valuePerDivision: timePerDivision } = this.calculateBase(timeRange, timeAxisDivisions, clientWidth);
const { scale: mvPerPixel, valuePerDivision: amplitudePerDivision } = this.calculateBase(voltageRange, amplitudeAxisDivisions, clientHeight);
// Draw the grid and axes
this.drawGrid(centerX, centerY, nsPerPixel, mvPerPixel, timePerDivision, amplitudePerDivision);
// If a waveform sampling function is provided, draw the waveform
waveformSampler && this.drawWave(peakAmplitude, waveformPeriod, nsPerPixel, mvPerPixel, waveformSampler);
if (this.targetCtx) {
this.targetCtx.clearRect(0, 0, this.targetCanvas.width, this.targetCanvas.height);
this.targetCtx.save();
this.targetCtx.translate(this.targetCanvas.width / 2, this.targetCanvas.height / 2);
this.targetCtx.rotate(Math.PI / 2);
this.targetCtx.drawImage(this.canvas, -this.canvas.width / 2, -this.canvas.height / 2);
this.targetCtx.restore();
}
}
/**
* Draw the waveform on the canvas.
* @param peakAmplitude Peak voltage (mV).
* @param waveformPeriod Waveform period (ns).
* @param nsPerPixel Time axis scale (ns/pixel).
* @param mvPerPixel Amplitude axis scale (mV/pixel).
* @param waveformSampler Waveform sampling function.
*/
drawWave(peakAmplitude, waveformPeriod, nsPerPixel, mvPerPixel, waveformSampler) {
// Get canvas layout parameters
const { left, centerX, centerY, clientWidth } = this.clientArea;
const ctx = this.ctx;
// Calculate the actual drawing range (from the left padding to the right padding)
const startX = left; // Starting X coordinate (left padding)
const endX = clientWidth + left; // Ending X coordinate (left padding + available width)
// Initialize the drawing path
ctx.beginPath();
ctx.strokeStyle = this.options.waveColor; // Set the waveform color
ctx.lineWidth = 1.5; // Set the line width
// Iterate through each horizontal pixel position
for (let pixelX = startX; pixelX <= endX; pixelX++) {
// Calculate the horizontal offset of the current pixel relative to the center point
const xOffset = pixelX - centerX;
// Convert the pixel offset to a time value (ns)
const timeOffset = xOffset / nsPerPixel;
const periodOffset = (timeOffset % waveformPeriod + waveformPeriod) % waveformPeriod;
// Call the sampling function to get the waveform value and convert it to a vertical coordinate
const sampleValue = waveformSampler(waveformPeriod, periodOffset, timeOffset, (pixelX - startX) / clientWidth // Current drawing progress (0-1)
);
// Calculate the actual Y coordinate of the waveform point (based on the center point)
const yPos = centerY - (sampleValue * mvPerPixel);
// Draw the path (use moveTo for the first point, lineTo for subsequent points)
pixelX === startX ? ctx.moveTo(pixelX, yPos) : ctx.lineTo(pixelX, yPos);
}
// Draw the complete path
ctx.stroke();
}
/**
* Draw the oscilloscope grid and coordinate system.
* @param centerX Horizontal center coordinate of the canvas (pixels).
* @param centerY Vertical center coordinate of the canvas (pixels).
* @param xScale Horizontal scaling factor (pixels/unit).
* @param yScale Vertical scaling factor (pixels/unit).
* @param timePerDivision Width of each division on the time axis (nanoseconds/division).
* @param amplitudePerDivision Height of each division on the amplitude axis (millivolts/division).
*/
drawGrid(centerX, centerY, xScale, yScale, timePerDivision, amplitudePerDivision) {
// Clear the entire canvas area
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Set the grid line style
this.ctx.strokeStyle = this.options.gridColor;
this.ctx.lineWidth = 0.7;
// Draw vertical grid lines (time axis)
const ticksEachSide = this.options.timeAxisDivisions >> 1; // Calculate the number of grid lines to draw on each side
this.ctx.beginPath();
// Draw grid lines from the center to both sides
for (let divisionIndex = -ticksEachSide; divisionIndex <= ticksEachSide; divisionIndex++) {
// Calculate the X coordinate of the current grid line: center point + number of divisions * width per division * scaling factor
const x = centerX + divisionIndex * timePerDivision * xScale;
// Draw a vertical line from the top to the bottom
this.ctx.moveTo(x, this.options.padding.top);
this.ctx.lineTo(x, this.canvas.height - this.options.padding.bottom);
}
// Draw horizontal grid lines (amplitude axis)
const amplitudeTicksEachSide = this.options.amplitudeAxisDivisions >> 1;
// Draw grid lines from the center to the top and bottom
for (let divisionIndex = -amplitudeTicksEachSide; divisionIndex <= amplitudeTicksEachSide; divisionIndex++) {
// Calculate the Y coordinate of the current grid line: center point - number of divisions * height per division * scaling factor
const y = centerY - divisionIndex * amplitudePerDivision * yScale;
// Draw a horizontal line from left to right
this.ctx.moveTo(this.options.padding.left, y);
this.ctx.lineTo(this.canvas.width - this.options.padding.right, y);
}
this.ctx.stroke();
// Draw the center reference lines (using the axis color)
this.ctx.strokeStyle = this.options.axisColor;
this.ctx.lineWidth = 1;
// Draw the vertical center line (X axis)
this.ctx.beginPath();
this.ctx.moveTo(centerX, this.options.padding.top);
this.ctx.lineTo(centerX, this.canvas.height - this.options.padding.bottom);
// Draw the horizontal center line (Y axis)
this.ctx.moveTo(this.options.padding.left, centerY);
this.ctx.lineTo(this.canvas.width - this.options.padding.right, centerY);
this.ctx.stroke();
// Convert the time unit to a more readable format (ms/μs/ns)
const timeInfo = this.convertTimeUnit(timePerDivision);
// Format the amplitude value: round to an integer if greater than 100, keep one decimal place if less than 100
const ampValue = amplitudePerDivision.toFixed(amplitudePerDivision >= 100 ? 0 : 1);
// Set the label position (bottom left, 10px from the left, 5px from the bottom)
const labelX = this.options.padding.left + 10;
const labelY = this.canvas.height - this.options.padding.bottom + 5;
// Draw the amplitude axis label (using the waveform color)
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
this.ctx.fillStyle = this.options.waveColor;
this.ctx.fillText(`Amplitude Axis: ${ampValue}mV/division`, labelX, labelY);
// Draw the time axis label (using the text color)
this.ctx.fillStyle = this.options.textColor;
this.ctx.fillText(`Time Axis: ${timeInfo.value}${timeInfo.unit}/division`, labelX, labelY + 20);
}
/**
* Callback function to handle canvas size changes.
* @param newWidth New canvas width (pixels).
* @param newHeight New canvas height (pixels).
*/
handleResize(newWidth, newHeight) {
// Update the actual size of the canvas
this.canvas.width = newWidth;
this.canvas.height = newHeight;
// Set the canvas text style (using the font settings from the configuration)
this.ctx.font = `${this.options.fontSize} ${this.options.fontFamily}`;
// Destructure to get the padding configuration
const { top, left, right, bottom } = this.options.padding;
// Calculate the actual drawable area size (minus the padding)
const clientWidth = newWidth - left - right;
const clientHeight = newHeight - top - bottom;
// Update the client area information
this.clientArea = {
top, // Top padding
left, // Left padding
right, // Right padding
bottom, // Bottom padding
clientWidth, // Available width
clientHeight, // Available height
centerX: clientWidth / 2 + left, // Horizontal center point (X coordinate)
centerY: clientHeight / 2 + top // Vertical center point (Y coordinate)
};
// Trigger waveform redraw when the available area is valid (width and height > 0)
if (clientWidth > Oscilloscope.MinCanvasSize &&
clientHeight > Oscilloscope.MinCanvasSize)
this.drawWaveform();
}
/**
* Initialize the resize observer.
* Determine the observation target and calculation method based on whether the canvas fills the parent container.
*/
initResizeObserver(canvas) {
// Check if the canvas is set to 100% width and height (fills the parent container)
const isCanvasFullSize = canvas.style.width === "100%" && canvas.style.height === "100%";
// Determine the resize target: if it's full size, observe the canvas itself; otherwise, observe the parent element
const resizeTarget = isCanvasFullSize ? canvas : canvas.parentElement;
// Ensure the target element exists
if (!resizeTarget)
throw new Error('Canvas must have a parent element');
// Create a debounced resize handler
const handleResize = Oscilloscope.debounce(this.handleResize, 100);
// Handle non-full size mode
if (!isCanvasFullSize) {
this.observer = new ResizeObserver(entries => {
// Get the computed style of the target element
const computedStyle = window.getComputedStyle(resizeTarget);
// Calculate the total horizontal padding
const paddingHorizontal = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight);
// Calculate the total vertical padding
const paddingVertical = parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom);
// Call the handler function with the actual size minus the padding
canvas.width = resizeTarget.clientWidth - paddingHorizontal;
canvas.height = resizeTarget.clientHeight - paddingVertical;
this.options.isVertical ?
handleResize.call(this, canvas.height, canvas.width) :
handleResize.call(this, canvas.width, canvas.height);
});
}
// Handle full size mode
else {
this.observer = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.contentBoxSize) {
// Handle differences in browser APIs
const contentSize = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: entry.contentBoxSize;
canvas.width = contentSize.inlineSize;
canvas.height = contentSize.blockSize;
// Use the content box size directly
this.options.isVertical ?
handleResize.call(this, canvas.height, canvas.width) :
handleResize.call(this, canvas.width, canvas.height);
}
}
});
}
// Start observing the target element
this.observer.observe(resizeTarget);
}
/**
* Calculate the axis base value and scaling ratio.
* @param totalRange Total range value (time range in ns or voltage range in mV).
* @param divisionCount Number of axis divisions.
* @param availablePixels Available pixel size.
* @returns An object containing the scaling ratio and base value per division.
*/
calculateBase(totalRange, divisionCount, availablePixels) {
// Calculate the initial base value per division
const initialDivisionValue = totalRange / divisionCount;
// Calculate the order of magnitude (power of 10)
const magnitude = Math.pow(10, Math.floor(Math.log10(initialDivisionValue)));
// Normalize the value
const normalizedValue = initialDivisionValue / magnitude;
// Select an appropriate base factor from the standard coefficients
const baseFactor = [1, 2, 5, 10].find(factor => normalizedValue <= factor);
return {
scale: availablePixels / (baseFactor * magnitude * divisionCount), // Pixel/unit value ratio
valuePerDivision: baseFactor * magnitude, // Actual value represented by each division
};
}
/**
* Destroy resources related to the oscilloscope instance.
* This method is used to clean up resources related to the oscilloscope instance, mainly disconnect the ResizeObserver listener and clear the drawing context.
* After calling this method, the oscilloscope will no longer respond to window size change events and cannot continue drawing operations.
*/
dispose() {
// Check if the ResizeObserver instance exists
if (this.observer) {
// Disconnect the ResizeObserver listener, stop observing the target element's size changes
this.observer.disconnect();
// Set the ResizeObserver instance to undefined to release the reference
this.observer = undefined;
}
if (this.targetCanvas) {
this.targetCanvas = undefined;
this.targetCtx = undefined;
}
this.canvas = undefined;
this.ctx = undefined;
// Set the drawing context to undefined to release the reference
}
/**
* Convert the time value to a more readable unit representation.
* @param time Time value, in nanoseconds.
* @returns An object containing the converted time value and the corresponding time unit.
*/
convertTimeUnit(time) {
if (time >= 1e6)
return { value: time / 1e6, unit: 'ms' };
if (time >= 1e3)
return { value: time / 1e3, unit: 'μs' };
return { value: time, unit: 'ns' };
}
/**
* Draw a sine waveform.
* @param peakToPeak Peak-to-peak voltage (mV), the voltage difference between the highest and lowest points of the waveform.
* @param waveformPeriod Waveform period (ns).
*/
drawSinWave(peakToPeak, waveformPeriod) {
// Calculate the half amplitude (half of the peak-to-peak value)
const halfPeak = peakToPeak / 2;
this.drawWaveform(halfPeak, waveformPeriod, (period, periodOffset) => {
return (Math.sin(periodOffset / period * Oscilloscope.PI2) * halfPeak);
});
}
/**
* Draw a frequency-swept sine wave.
* @param peakToPeak Peak-to-peak voltage (mV).
* @param startFreq Starting frequency (Hz).
* @param endFreq Ending frequency (Hz).
* @param bidirectional Whether to perform bidirectional scanning.
*/
drawSineSweepWave(peakToPeak, startFreq, endFreq, bidirectional = false) {
// Parameter validation (clearer error messages)
if (peakToPeak <= 0 || startFreq <= 0 || endFreq <= 0) {
throw new Error(`Parameters must be positive numbers, received: peakToPeak=${peakToPeak}, startFreq=${startFreq}, endFreq=${endFreq}`);
}
// Use an arrow function to bind the this context (improve code stability)
const createWave = () => {
const halfAmplitude = peakToPeak / 2;
const freqScale = 1e-9; // Hz -> 1/ns conversion factor
// Pre-calculate frequency parameters (improve readability)
const start = startFreq * freqScale;
const delta = (endFreq * freqScale) - start;
let accumulatedPhase = 0;
let previousTime = 0;
return (_, __, timeOffset, progress) => {
// Use the cached progress to calculate the current frequency (reduce redundant calculations)
const effectiveProgress = bidirectional
? progress < 0.5
? progress * 2
: 2 - progress * 2
: progress;
const currentFreq = start + delta * effectiveProgress;
// Optimize phase integration calculation (avoid floating-point error accumulation)
if (previousTime !== 0) {
const deltaTime = timeOffset - previousTime;
accumulatedPhase += (currentFreq * deltaTime) * Oscilloscope.PI2;
}
previousTime = timeOffset;
return Math.sin(accumulatedPhase) * halfAmplitude;
};
};
// Use default period parameters (the actual period is dynamically determined by the sweep logic)
this.drawWaveform(peakToPeak / 2, 1e9 / Math.min(startFreq, endFreq), createWave());
}
/**
* Draw a unipolar pulse waveform.
* @param peakAmplitude Pulse peak amplitude (mV).
* @param waveformPeriod Waveform period (ns).
* @param pulseWidth Pulse width (ns).
*/
drawPulseWave(peakToPeak, waveformPeriod, pulseWidth) {
// Parameter validation
if ([peakToPeak, waveformPeriod, pulseWidth].some(v => v <= 0)) {
throw new Error('Parameters must be positive numbers');
}
if (pulseWidth > waveformPeriod) {
throw new Error('Pulse width cannot exceed the period');
}
// Pre-calculate the average amplitude (optimize performance)
const avgAmplitude = peakToPeak * (pulseWidth / waveformPeriod);
const actualPeek = Math.max(peakToPeak - avgAmplitude, avgAmplitude);
this.drawWaveform(actualPeek, waveformPeriod, (_, periodOffset) => {
// Check if it's within the pulse interval
return periodOffset < pulseWidth
? peakToPeak - avgAmplitude // Positive pulse part
: -avgAmplitude; // Negative level part
});
}
/**
* Draw a bipolar pulse waveform.
* @param peakToPeak Peak-to-peak voltage (mV), the voltage difference between the highest and lowest points of the waveform.
* @param waveformPeriod Waveform period (ns).
* @param pulseWidth Pulse width (ns).
*/
drawBipolarPulse(peakToPeak, waveformPeriod, pulseWidth) {
// Parameter validation
if ([peakToPeak, waveformPeriod, pulseWidth].some(v => v <= 0)) {
throw new Error('Parameters must be positive numbers');
}
if (pulseWidth > waveformPeriod / 2) {
throw new Error('Pulse width cannot exceed half of the period');
}
// Calculate the half amplitude (half of the peak-to-peak value)
const halfPeak = peakToPeak / 2;
const halfPeriod = waveformPeriod / 2;
this.drawWaveform(halfPeak, waveformPeriod, (_, periodOffset, timeOffset) => {
// Check if it's within the positive pulse interval
if (periodOffset < pulseWidth)
return halfPeak;
// Check if it's within the negative pulse interval
if (periodOffset > halfPeriod && periodOffset < halfPeriod + pulseWidth)
return -halfPeak;
// Return 0 at other times
return 0;
});
}
/**
* Draw a triangle waveform.
* @param peakToPeak - Peak-to-peak voltage (mV), the voltage difference between the highest and lowest points of the waveform.
* @param waveformPeriod - Waveform period (ns), the time required to complete one full waveform.
* @param pulseWidth - Rise time (ns), the time required to rise from the trough to the peak.
*/
drawTriangleWave(peakToPeak, waveformPeriod, pulseWidth) {
// Parameter validation: ensure all input values are positive
if ([peakToPeak, waveformPeriod].some(v => v <= 0)) {
throw new Error('Parameters must be greater than zero');
}
// Calculate the half amplitude (half of the peak-to-peak value)
const halfPeak = peakToPeak / 2;
// Calculate the slopes: rise slope = peak-to-peak value / rise time, fall slope = peak-to-peak value / fall time
const slopeUp = peakToPeak / pulseWidth;
const slopeDown = peakToPeak / (waveformPeriod - pulseWidth);
// Call the core drawing function with the triangle wave generator
this.drawWaveform(halfPeak, waveformPeriod, (_, periodOffset) => {
// Return the waveform value based on the phase position:
// Rising phase: linearly increase from -halfPeak to +halfPeak
// Falling phase: linearly decrease from +halfPeak to -halfPeak
return periodOffset < pulseWidth ?
-halfPeak + slopeUp * periodOffset :
halfPeak - slopeDown * (periodOffset - pulseWidth);
});
}
}
exports.Oscilloscope = Oscilloscope;