drawings.js

/**
 * @file drawings.js
 * @description This file contains every function related to the canvas drawings & interactions.
 * 
 * Functions included in this file :
 *  - **clearCanvas**
 *  - **drawCursors**
 *  - **drawSignal**
 *  - **drawFFT**
 *  - **drawGrid**
 *  - **removeSpikes**
 *  - **drawZoomRectangle**
 *  - **resetZoom**
 *  - **drawTriggerCursor**
 * 
 * @version 1.0.0
 * @since 2024-05-31
 * @author Owen Pichot
 * 
 * @license Public Domain
 */

/**
 * @module Drawings
 */

/**
 * Completely clears the canvas' content.
 * 
 * @function clearCanvas
 * @memberof module:Drawings
 * @returns {void}
 */
function clearCanvas(){
    let ctx = CANVAS.getContext('2d');
    ctx.clearRect(0, 0, CANVAS.width, CANVAS.height);
};

/**
 * This function is here to draw the vertical and horizontal measure cursors on screen.
 * It also draws the lines going to and from the cursors and also the text displaying the position of each line.
 * 
 * @function drawCursors
 * @memberof module:Drawings
 * @returns {void}
 */
function drawCursors(){
    const ctx = CANVAS.getContext('2d');

    const canvasWidth = CANVAS.width;

    if (cursorOptions.isVerticalCursorOn == "true"){
        ctx.setLineDash([12, 8]);/*dashes are 12px and spaces are 8px*/
        ctx.strokeStyle = 'red';
        ctx.lineWidth = 2;
        ctx.globalAlpha = 0.6;
        ctx.font = "bold 18px Arial";
        ctx.fillStyle = 'red';
    
        //draw first line
        ctx.beginPath();
        ctx.moveTo(cursorOptions.verticalAPosition, 0);
        ctx.lineTo(cursorOptions.verticalAPosition, CANVAS.height);
        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            const cursorATime = getTimeForACursor(cursorOptions.verticalAPosition)
            const text = `${cursorATime.value} ${cursorATime.scale}`;
            ctx.fillText(text, cursorOptions.verticalAPosition + 10, 50);
        }else{
            ctx.fillText("A", cursorOptions.verticalAPosition + 10, 50);
        }
        ctx.stroke();
    
        ctx.strokeStyle = 'crimson';
        ctx.fillStyle = 'crimson';

        //draw second line
        ctx.beginPath();
        ctx.moveTo(cursorOptions.verticalBPosition, 0);
        ctx.lineTo(cursorOptions.verticalBPosition, CANVAS.height);
        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            const cursorBTime = getTimeForACursor(cursorOptions.verticalBPosition)
            const text2 = `${cursorBTime.value} ${cursorBTime.scale}`;
            ctx.fillText(text2, cursorOptions.verticalBPosition - 70, 50);
        }else{
            ctx.fillText("B", cursorOptions.verticalBPosition - 20, 50);
        }
        ctx.stroke();
    
        //draw perpendicular line
        ctx.beginPath();
        ctx.lineWidth = 1;
        ctx.globalAlpha = 0.5;
        if (config.theme == "dark"){
            ctx.fillStyle = 'white';
            ctx.strokeStyle = 'white';
        }else{
            ctx.fillStyle = 'black';
            ctx.strokeStyle = 'black';
        }
        
    
        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            
            let pixelsBetweenCursors;
            if (cursorOptions.verticalAPosition > cursorOptions.verticalBPosition){
                pixelsBetweenCursors = cursorOptions.verticalAPosition - cursorOptions.verticalBPosition;
            }else{
                pixelsBetweenCursors = cursorOptions.verticalBPosition - cursorOptions.verticalAPosition;
            }

            const timeBetweenCursors = getTimeBetweenCursors(pixelsBetweenCursors);
            const text3 = `Δ ${timeBetweenCursors.value} ${timeBetweenCursors.scale}`;
            const textMetrics = ctx.measureText(text3);
            const textWidth = textMetrics.width;

            let X = ((cursorOptions.verticalAPosition + cursorOptions.verticalBPosition) / 2) - (textWidth/2);
            let Y = CANVAS.height * 0.80 - 10;
            ctx.fillText(text3, X, Y);
        }else{
            let X = (cursorOptions.verticalAPosition + cursorOptions.verticalBPosition) / 2;
            let Y = CANVAS.height * 0.80 - 10;
            ctx.fillText("Δ", X - 7, Y);
        }
        
        ctx.setLineDash([8, 4]);
        ctx.moveTo(Math.min(cursorOptions.verticalAPosition, cursorOptions.verticalBPosition), CANVAS.height * 0.80);
        ctx.lineTo(Math.max(cursorOptions.verticalAPosition, cursorOptions.verticalBPosition), CANVAS.height * 0.80);
        ctx.stroke();
    
        ctx.setLineDash([]);
    };

    if (cursorOptions.isHorizontalCursorOn == "true"){
        ctx.setLineDash([12, 8]);
        ctx.strokeStyle = 'orange';
        ctx.lineWidth = 2;
        ctx.globalAlpha = 0.6;
        ctx.font = "bold 18px Arial";
        ctx.fillStyle = 'orange';

        //draw first line
        ctx.beginPath();
        ctx.moveTo(0, cursorOptions.horizontalAPosition); // Move to the left edge of the canvas at a specific horizontal position
        ctx.lineTo(CANVAS.width, cursorOptions.horizontalAPosition); // Draw line to the right edge of the canvas
        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            const MvCursorA = getMilliVoltForACursor(cursorOptions.horizontalAPosition);
            const text = `${MvCursorA.value} ${MvCursorA.scale}`;
            ctx.fillText(text, CANVAS.width - 100, cursorOptions.horizontalAPosition -10); // Adjust text position to follow the line
        }else{
            ctx.fillText("A", CANVAS.width - 20, cursorOptions.horizontalAPosition -10);
        }
        ctx.stroke();
    
        ctx.strokeStyle = 'darkorange';
        ctx.fillStyle = 'darkorange';

        //draw second line
        ctx.beginPath();
        ctx.moveTo(0, cursorOptions.horizontalBPosition); // Move to the left edge of the canvas at another specific horizontal position
        ctx.lineTo(CANVAS.width, cursorOptions.horizontalBPosition); // Draw line to the right edge of the canvas
        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            const MvCursorB = getMilliVoltForACursor(cursorOptions.horizontalBPosition);
            const text2 = `${MvCursorB.value} ${MvCursorB.scale}`;
            ctx.fillText(text2, CANVAS.width - 100, cursorOptions.horizontalBPosition + 22); // Adjust text position to follow the line, slightly offset vertically
        }else{
            ctx.fillText("B", CANVAS.width - 20, cursorOptions.horizontalBPosition + 22);
        }
        ctx.stroke();


        // Draw perpendicular line
        ctx.beginPath();
        ctx.lineWidth = 1;
        ctx.globalAlpha = 0.5;

        if (config.theme == "dark"){
            ctx.fillStyle = 'white';
            ctx.strokeStyle = 'white';
        }else{
            ctx.fillStyle = 'black';
            ctx.strokeStyle = 'black';
        }

        if (cursorOptions.cursorsValueDisplay != "indisplay"){
            let pixelsBetweenCursors;
            if (cursorOptions.horizontalAPosition > cursorOptions.horizontalBPosition){
                pixelsBetweenCursors = cursorOptions.horizontalAPosition - cursorOptions.horizontalBPosition;
            }else{
                pixelsBetweenCursors = cursorOptions.horizontalBPosition - cursorOptions.horizontalAPosition;
            }
    
            const milliVoltsBetweenCursors = getMillivoltsBetweenCursors(pixelsBetweenCursors);
    
            const text3 = `Δ ${milliVoltsBetweenCursors.value} ${milliVoltsBetweenCursors.scale}`;
            let Y = (cursorOptions.horizontalAPosition + cursorOptions.horizontalBPosition) / 2;
            let X = CANVAS.width * 0.10 + 10;
            ctx.fillText(text3, X, Y);
        }else{
            let Y = (cursorOptions.horizontalAPosition + cursorOptions.horizontalBPosition) / 2;
            let X = CANVAS.width * 0.10 + 10;
            ctx.fillText("Δ", X, Y);  
        }

        ctx.setLineDash([8, 4]);
        ctx.moveTo(CANVAS.width * 0.10, Math.min(cursorOptions.horizontalAPosition, cursorOptions.horizontalBPosition));
        ctx.lineTo(CANVAS.width * 0.10, Math.max(cursorOptions.horizontalAPosition, cursorOptions.horizontalBPosition));
        ctx.stroke();

        ctx.setLineDash([]);
    };

    if (cursorOptions.cursorsValueDisplay == "indisplay"){
        ctx.setLineDash([]);
        ctx.strokeStyle = 'yellow';
        ctx.fillStyle = 'lightgoldenrodyellow';
        ctx.lineWidth = 5;
        ctx.globalAlpha = 0.8;
    
        ctx.beginPath();
    
        let rectWidth, rectHeight, y

        if (canvasWidth < 1100){
            rectWidth = 150;
            rectHeight = 80;
            y = 5;
            ctx.font = "12px Arial";
        }else if (canvasWidth < 700){
            rectWidth = 150;
            rectHeight = 80;
            y = 2;
            ctx.font = "10px Arial";
        }else{
            rectWidth = 220;
            rectHeight = 120;
            y = 10
            ctx.font = "16px Arial";
        }

    
        let x = CANVAS.width - rectWidth;
    
        ctx.rect(x - 10, y, rectWidth, rectHeight);
        ctx.fill();
        ctx.stroke();

        ctx.fillStyle = 'black';
        ctx.globalAlpha = 1.0;

        const cursorATime = getTimeForACursor(cursorOptions.verticalAPosition)
        const cursorBTime = getTimeForACursor(cursorOptions.verticalBPosition)
        let pixelsBetweenCursors;
        if (cursorOptions.verticalAPosition > cursorOptions.verticalBPosition){
            pixelsBetweenCursors = cursorOptions.verticalAPosition - cursorOptions.verticalBPosition;
        }else{
            pixelsBetweenCursors = cursorOptions.verticalBPosition - cursorOptions.verticalAPosition;
        }
        const timeBetweenCursors = getTimeBetweenCursors(pixelsBetweenCursors);

        let textA_us = `A: ${cursorATime.value} ${cursorATime.scale}`;
        let textB_us = `B: ${cursorBTime.value} ${cursorBTime.scale}`;
        let textD_us = `Δ: ${timeBetweenCursors.value} ${timeBetweenCursors.scale}`;

        const MvCursorA = getMilliVoltForACursor(cursorOptions.horizontalAPosition);    
        const MvCursorB = getMilliVoltForACursor(cursorOptions.horizontalBPosition);
        let pixelsBetweenCursorsHorizontal;
        if (cursorOptions.horizontalAPosition > cursorOptions.horizontalBPosition){
            pixelsBetweenCursorsHorizontal = cursorOptions.horizontalAPosition - cursorOptions.horizontalBPosition;
        }else{
            pixelsBetweenCursorsHorizontal = cursorOptions.horizontalBPosition - cursorOptions.horizontalAPosition;
        }
        const milliVoltsBetweenCursors = getMillivoltsBetweenCursors(pixelsBetweenCursorsHorizontal);

        let textA_mV = `A: ${MvCursorA.value} ${MvCursorA.scale}`;
        let textB_mV = `B: ${MvCursorB.value} ${MvCursorB.scale}`;
        let textD_mV = `Δ: ${milliVoltsBetweenCursors.value} ${milliVoltsBetweenCursors.scale}`;

        let padding, textY1, textY2, textY3, textY4

        if (canvasWidth < 1100){
            padding = 1;
            textY1 = y + padding + 15;
            textY2 = textY1 + 15;
            textY3 = y + rectHeight / 2 + padding + 15;
            textY4 = textY3 + 15;
        }else{
            padding = 10;
            textY1 = y + padding + 20;
            textY2 = textY1 + 20;
            textY3 = y + rectHeight / 2 + padding + 20;
            textY4 = textY3 + 20;
        }


        let spaceBetween = rectWidth / 2;

        ctx.fillText(textA_us, x, textY1);
        ctx.fillText(textB_us, x + spaceBetween, textY1);
        ctx.fillText(textD_us, x + rectWidth / 4, textY2);

        ctx.fillText(textA_mV, x, textY3);
        if (MvCursorA.value != "No channel"){
            ctx.fillText(textB_mV, x + spaceBetween, textY3);
            ctx.fillText(textD_mV, x + rectWidth / 4, textY4);
        }

    };
};

/**
 * This function draws a signal onto the canvas' screen using the points from a channel.
 * 
 * @function drawSignal
 * @memberof module:Drawings
 * @param {string} channelKey Object key corresponding to a certain signal saved within 'channelData'.
 * @returns {void}
 */
function drawSignal(channelKey){
    const channel = channelData[channelKey];
    const points = channel.points;
    const ctx = CANVAS.getContext('2d');
    const width = CANVAS.width;
    const height = CANVAS.height;

    const maxSignalValue = config.maxSampleValue;  // Max value of the signal

    // Calculate the scaling factors
    const verticalScale = height / maxSignalValue * channel.verticalScale;
    const horizontalScaleFactor = width / points.length * (horizontalScale / 50);

    const baselineY = height / 2  

    // Start drawing the waveform
    ctx.beginPath();

    // Draw each point on the canvas
    points.forEach((point, index) => {
        const x = (index * horizontalScaleFactor) + horizontalOffset;
        const y = baselineY - ((point - maxSignalValue / 2) * verticalScale) + channel.verticalOffset;

        if (index === 0) {
            ctx.moveTo(x, y);
        } else {
            ctx.lineTo(x, y);
        }
    });

    if (config.theme == "dark"){
        ctx.strokeStyle = channel.colorDark;  // Color of the waveform
    }else{
        ctx.strokeStyle = channel.colorLight;
    }
    ctx.lineWidth = 2;
    ctx.globalAlpha = 0.8;
    ctx.stroke();

    if (zoomConfig.isZoomed){
        // Calculate rectangle dimensions
        const rectangleWidth = zoomConfig.finalX - zoomConfig.initX;
        const rectangleHeight = zoomConfig.finalY - zoomConfig.initY;

        const text = `${rectangleWidth}px * ${rectangleHeight}px`;
        ctx.fillText(text, zoomConfig.initX + 5, zoomConfig.initY + 5);
    }

};

/**
 * This function is fairly similar to 'drawSignal' but is specific to FFT signals to draw.
 * The main reason for it being a separate function is that we add text along the signal.
 * We also need to filter out empty values caused by fft.js.
 * This is due to the fact that fft.js requires arrays with a size of a power of 2, so we fill the array with zeroes until the next power of two.
 * 
 * @function drawFFT
 * @memberof module:Drawings
 * @param {string} channelKey Object key corresponding to a certain signal saved within 'channelData'.
 * @returns {void}
 */
function drawFFT(channelKey) {
    const channel = channelData[channelKey];
    const points = channel.points;
    const ctx = CANVAS.getContext('2d');
    const width = CANVAS.width;
    const height = CANVAS.height;
    
    //Enable access for the user to the RATE variable so they can choose the rate themselves.
    const RATE = 1; // how many points to skip when drawing the FFT (min=1)

    const validPoints = points.filter(p => p.magnitude !== null);

    if (validPoints.length > 10) {
        const maxFrequency = validPoints[validPoints.length / 2 - 1].frequency;
        const maxMagnitude = Math.max(...validPoints.map(p => p.magnitude));

        const verticalOffset = channel.verticalOffset;
        const verticalScale = channel.verticalScale;

        ctx.beginPath();
        if (config.theme == "dark"){
            ctx.strokeStyle = channel.colorDark;  // Color of the waveform
            ctx.fillStyle = channel.colorDark;
        }else{
            ctx.strokeStyle = channel.colorLight;
            ctx.fillStyle = channel.colorLight;
        }
        ctx.lineWidth = 1;
        ctx.font = "bold 18px Arial";
        ctx.textBaseline = 'middle';
        ctx.globalAlpha = 1.0;

        let previousX = 0; // Initial value to compare against

        validPoints.slice(0, validPoints.length / 2).forEach((point, index) => {
            if (index % RATE === 0) { // draw only every RATE-th point
                const x = (point.frequency / maxFrequency) * width + horizontalOffset ;
                const y = height * (1 - Math.log10(point.magnitude + 1) / (Math.log10(maxMagnitude + 1) * verticalScale)) + verticalOffset;

                if (index === 0) {
                    ctx.moveTo(0, 500); // Move to initial x, y (possibly needs adjustment)
                } else {
                    ctx.lineTo(x, y);
                }

                if (x >= previousX + 200) {
                    previousX = x;
                    const text = formatFrequency(point.frequency);
                    ctx.fillText(text, x, y - 60);
                }
            }
        });
        ctx.stroke();
    } else {
        console.log("Not enough valid data to display.");
    }
};

/**
 * This function draws the grid on the screen of the canvas.
 * Users can modify its opacity.
 * 
 * @function drawGrid
 * @memberof module:Drawings
 * @param {string} gridColor Color assigned to the lines of the grid.
 * @param {number} thickerLineWidth Thickness of the central lines (horizontal & vertical).
 * @returns {void}
 */
function drawGrid(gridColor, thickerLineWidth) {
    if (config.gridDisplay == 0){return;}
    
    let ctx = CANVAS.getContext('2d');
    ctx.globalAlpha = config.gridOpacity;

    const gridSizeVertical = CANVAS.width / config.horizontalDivisions;
    const gridSizeHorizontal = CANVAS.height / config.verticalDivisions;
    const centerVertical = CANVAS.width / 2;
    const centerHorizontal = CANVAS.height / 2;
    //We need the tolerance for a little wiggle room when detecting the two central lines.
    //Otherwise we might not get a perfect == when checking the position of the drawer compared to CANVAS.width/2 or CANVAS.height/2.
    const tolerance = 0.5;

    // Draw vertical grid lines
    for (let x = gridSizeVertical; x < CANVAS.width; x += gridSizeVertical) {
        if (Math.abs(x - centerVertical) <= tolerance) {
            // Make the central vertical line thicker
            ctx.strokeStyle = gridColor;
            ctx.lineWidth = thickerLineWidth;
        } else {
            // Reset the line width to the default value
            ctx.strokeStyle = gridColor;
            ctx.lineWidth = 1;
        }
        ctx.beginPath();
        ctx.moveTo(x, 0);
        ctx.lineTo(x, CANVAS.height);
        ctx.stroke();
    }

    // Draw horizontal grid lines
    for (let y = gridSizeHorizontal; y < CANVAS.height; y += gridSizeHorizontal) {
        if (Math.abs(y - centerHorizontal) <= tolerance) {
            // Make the central horizontal line thicker
            ctx.strokeStyle = gridColor;
            ctx.lineWidth = thickerLineWidth;
        } else {
            // Reset the line width to the default value
            ctx.strokeStyle = gridColor;
            ctx.lineWidth = 1;
        }
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(CANVAS.width, y);
        ctx.stroke();
    }
};

/**
 * This function is used when gathering raw data.
 * It filters out big spikes within the signal that are the result of a trigger point in the data.
 * We check for high and low spikes and remove them from the signal to keep only the valid data points.
 * 
 * @function removeSpikes
 * @memberof module:Drawings
 * @param {number[]} points Points from a channel.
 * @param {number} thresholdRatio Arbitrary number corresponding to the acceptable limit at which we consider a point a spike.
 * The smaller the number the more the signal will be streamlined.
 * 3 is a good baseline.
 * @returns {number[]} The points array 'cleaned out'.
 */
function removeSpikes(points, thresholdRatio) {
    const pointsAverage = points.reduce((a, b) => a + b, 0) / points.length;

    function isSpike(point, index) {
        let prev = points[index - 1] || pointsAverage;
        let next = points[index + 1] || pointsAverage;
        let localAverage = (prev + next) / 2;
        let deviationFromLocalAverage = Math.abs(point - localAverage);
        return deviationFromLocalAverage > thresholdRatio * Math.abs(localAverage - pointsAverage);
    }

    // Replace spikes with the average of their neighboring points
    for (let i = 0; i < points.length; i++) {
        if (isSpike(points[i], i)) {
            let prev = points[i - 1] || pointsAverage;
            let next = points[i + 1] || pointsAverage;
            points[i] = (prev + next) / 2;
        }
    }

    return points;
};

/**
 * This function draws the rectangle the user sees when dragging their mouse onto the canvas to zoom-in.
 * 
 * @function drawZoomRectangle
 * @memberof module:Drawings
 * @returns {void}
 */
function drawZoomRectangle(){
    const ctx = CANVAS.getContext('2d');
    ctx.globalAlpha = 0.4;
    if (config.theme == "dark"){
        ctx.strokeStyle = 'white';
        ctx.fillStyle = 'white';
    }else{
        ctx.strokeStyle = 'black';
        ctx.fillStyle = 'black';
    }

    // Calculate rectangle dimensions
    const width = zoomConfig.finalX - zoomConfig.initX;
    const height = zoomConfig.finalY - zoomConfig.initY;

    ctx.beginPath();
    ctx.rect(zoomConfig.initX, zoomConfig.initY, zoomConfig.finalX - zoomConfig.initX, zoomConfig.finalY - zoomConfig.initY);
    ctx.fill();
    ctx.stroke();

    ctx.globalAlpha = 1;
    ctx.font = '12px Arial';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'top';

    // Draw the dimensions at the top left corner of the rectangle
    const text = `${width}px x ${height}px`;
    ctx.fillText(text, zoomConfig.initX + 5, zoomConfig.initY + 5);

};

/**
 * This function resets the zoom setup by a user on the canvas' screen.
 * N.B: It can be called via a button in the sub-menu DISPLAY, or by pressing 'Shift+X'.
 * 
 * @function resetZoom
 * @memberof module:Drawings
 * @returns {void}
 */
function resetZoom(){
    const ctx = CANVAS.getContext('2d');
    ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform to identity
    zoomConfig.isZoomed = false;
};

/**
 * This function draws the trigger cursor when the user activates the trigger options.
 * 
 * @function drawTriggerCursor
 * @memberof module:Drawings
 * @returns {void}
 */
function drawTriggerCursor(){
    const ctx = CANVAS.getContext('2d');

    const triggerScroller = document.getElementById("trigger-cursor");

    ctx.setLineDash([]);
    ctx.strokeStyle = 'blue';
    ctx.fillStyle = 'blue';
    ctx.lineWidth = 2;
    ctx.globalAlpha = 0.8;

    const Y = parseInt(triggerScroller.style.top, 10) + 10;

    ctx.beginPath();
    ctx.moveTo(0, Y);
    ctx.lineTo(CANVAS.width, Y);
    ctx.stroke();
};