UNPKG

canvas-oscilloscope

Version:

HTML5 Canvas oscilloscope simulator with responsive rendering, customizable waveforms, and real-time visualization capabilities.

534 lines (533 loc) 28.9 kB
"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;