main.js

/**
 * @file main.js
 * @description This is the main script file for the project. It is responsible for the main loop drawing the signals on the oscilloscope screen and fetching the data beforehand.
 *              Most global variables declared within this file are used throughout all other files.
 * Functions included in this file : 
 *  - **getCurrentSettings**
 *  - **fetchDataFromFile**
 *  - **fetchRawData**
 *  - **saveColorChoices**
 *  - **environmentSetup**
 *  - **MAINLOOP**
 * 
 * @version 1.0.0
 * @since 2024-05-31
 * 
 * @author Owen Pichot
 * 
 * @license Public Domain
 * 
*/

/**
 * @namespace VARIABLES
 * @description This section regroups every object and variable needed globally for the whole app.
 */

/**
 * @module Main
 */

/**
 * Configuration object used to handle the server configuration.
 * If the server configuration changes, update this object accordingly.
 * 
 * @memberof VARIABLES
 * 
 * @type {Object}
 * @property {number|null} numChannels - Number of expected channels.
 * @property {number|null} frequency - Frequency in Hz.
 * @property {number|null} samplesPerFrame - Number of samples per frame.
 * @property {number|null} voltage - Voltage range (from + to -).
 * @property {number|null} bitsPerSample - Bits per sample.
 * @property {number} verticalDivisions - Number of vertical divisions. Default is 16.
 * @property {number} horizontalDivisions - Number of horizontal divisions. Default is 20.
 * @property {string|null} mode - Operating mode (FILE / REAL-TIME).
 * @property {number|null} maxSampleValue - Maximum sample value (absolute).
 * @property {boolean} gridDisplay - Grid display status. 1 means on, 0 means off.
 * @property {number} gridOpacity - Grid opacity level (0 - 1).
 * @property {string} theme - Theme of the display (dark / light).
*/
let config = {
    numChannels: null,
    frequency: null,
    samplesPerFrame: null,
    voltage: null,
    bitsPerSample: null,
    verticalDivisions: 16,
    horizontalDivisions: 20,
    mode: null,
    maxSampleValue: null,
    gridDisplay: 1,
    gridOpacity: 0.5,
    theme: "dark",
};

/**
 * Object used to handle the trigger's behavior and options selected by the user.
 * 
 * @memberof VARIABLES
 * 
 * @type {object}
 * @property {boolean} isTriggerOn - Is the trigger currently on (on / off).
 * @property {string} triggerMode - Which mode is the trigger set to (edge / window).
 * @property {string} triggerChannel - Which channel are we triggering for (CH1, CH2, etc..).
 * @property {number} triggerLevel - Value in Mv at which the trigger will activate (if triggerMode == edge).
 * @property {number} windowLevelMin - Value A in mV of the window in which the trigger will activate (if triggerMode == window).
 * @property {number} windowLevelMax - Value B in mV of the window in which the trigger will activate (if triggerMode == window).
 * @property {string} triggerSlope - Slope to check for when triggering (rising, falling, both).
 * @property {number} holdOff - Timeout for the trigger once activated (0-3600) (seconds).
 *
*/
let triggerOptions = {
    isTriggerOn: "off",
    triggerMode: "edge",
    triggerChannel: "CH1",
    triggerLevel: "500",
    windowLevelMin: "500",
    windowLevelMax: "500",
    triggerSlope: "both",
    holdOff: "3600",
};

/**
 * Object used to keep track of the current status of the cursors that can be displayed on screen for measures.
 * This does not concern the offset cursors, only those generated from the "CURSORS" menu.
 * 
 * @memberof VARIABLES
 * 
 * @type {object}
 * @property {boolean} isVerticalCursorOn - Saves wether or not we need to display the vertical cursors with each frame (true / false).
 * @property {boolean} isHorizontalCursorOn - Saves wether or not we need to display the horizontal cursors with each frame (true / false).
 * @property {string} cursorsValueDisplay - Are we displaying each cursor's value near the line going to the cursor or within a frame in the top-right of the screen (oncursor / indisplay).
 * @property {number} horizontalAPosition - Distance from the top of the scrollbar to the horizontal cursor A (pixels).
 * @property {number} horizontalBPosition - Distance from the top of the scrollbar to the horizontal cursor B (pixels).
 * @property {number} verticalAPosition - Distance from the left of the scrollbar to the vertical cursor A (pixels).
 * @property {number} verticalBPosition - Distance from the left of the scrollbar to the vertical cursor B (pixels).
*/
let cursorOptions = {
    isVerticalCursorOn: "false",
    isHorizontalCursorOn: "false",
    cursorsValueDisplay: "oncursor",
    horizontalAPosition: 266,
    horizontalBPosition: 533,
    verticalAPosition: 400,
    verticalBPosition: 800,
};

/**
 * Object used with the zoom option that allows a user to drag their mouse onto the oscilloscope's screen and zoom on the selected area.
 * 
 * @memberof VARIABLES
 * 
 * @type {object}
 * @property {boolean} isZoomed - Is the screen currently zoomed in or not (true / false).
 * @property {boolean} isDrawing - Is the user currently dragging their mouse onto the oscilloscope's screen (true / false).
 * @property {number} initX - The initial X coordinate where the user started dragging.
 * @property {number} initY - The initial Y coordinate where the user started dragging.
 * @property {number} finalX - The final X coordinate where the user stopped dragging.
 * @property {number} finalY - The final Y coordinate where the user stopped dragging.
 * @property {number} zoomX - The factor by which the X axis is zoomed.
 * @property {number} zoomY - The factor by which the Y axis is zoomed.
*/
let zoomConfig = {
    isZoomed: false,
    isDrawing: false,
    initX: 0,
    initY: 0,
    finalX: 0,
    finalY: 0,
    zoomX: 0,
    zoomY: 0,
}

/**
 * Object used to keep track of which "autoMeasure" are currently actives.
 * These are the small values seen at the bottom of the screen to display informations such as highest value, lowest value, mean value, etc..
 * 
 * @memberof VARIABLES
 * 
 * @type {object}
 * @property {string} associatedChannel - Which channel are the measurements based on.
 * @property {boolean} min - Is the smallest value (mV) displayed.
 * @property {boolean} max - Is the highest value (mV) displayed.
 * @property {boolean} vpp - Is the VPP (mV) displayed.
 * @property {boolean} mean - Is the mean value (mV) displayed.
 * @property {boolean} rms - Is the RMS (Root mean square) displayed.
 * @property {boolean} freq - Is the average frequency displayed (Hz).
 * @property {boolean} highFreq - Is the highest frequency displayed (Hz).
 * @property {boolean} lowFreq - Is the lowest frequency displayed (Hz).
 * @property {boolean} mid - Is the middle value of the signal displayed (mV).
 */
let autoMeasureOptions = {
    associatedChannel: "CH1",
    min: {set: false},
    max: {set: false},
    vpp: {set: false},
    mean: {set: false},
    rms: {set: false},
    freq: {set: false},
    highFreq: {set: false},
    lowFreq: {set: false},
    mid: {set: false},
};

let LOOP;

/**
 * This object holds everything needed for the display and interaction with a signal.
 * Every signal's data is stored within 'channelData' under the key name "CH" + Number of the signal (CH1, CH2, etc..). 
 * 
 * @memberof VARIABLES
 * 
 * @type {object}
 * @property {string} colorDark - Color of the signal on screen if the theme is set to dark.
 * @property {string} colorLight - Color of the signal on screen if the theme is set to light.
 * @property {boolean} display - Is the signal displayed or hidden on the screen.
 * @property {boolean} focused - Is the signal focused or not (channel button highlighted).
 * @property {array} points - Every point from a frame N to draw the signal.
 * @property {string} type - Signal type, in case a signal is the result of an operation from another signal (baseData / generatedData).
 * @property {number} verticalOffset - Offset for the signal.
 * @property {number} verticalOffsetRelativeCursorPosition - Position of the cursor relative to this signal for the vertical offset.
 * @property {number} verticalScale - Vertical scale for this signal
 * @property {string} originChannel1 **If generated signal** - Original signal 1.
 * @property {string} originChannel2 **If generated signal** - Original signal 2.
 * @property {string} operation **If generated signal** - Operation type.
 */
let channelData = {};

/**
 * @memberof VARIABLES
 * @type {number}
 * @description Horizontal offset for every channel.
 */
let horizontalOffset = 0;
/**
 * @memberof VARIABLES
 * @type {number}
 * @description Horizontal scale for every channel.
 */
let horizontalScale = 50;
/**
 * @memberof VARIABLES
 * @type {number}
 * @description Time between each frame & data gathering (ms).
 */
let loopDelay = 100
/**
 * Is the oscilloscope currently running or not.
 * @memberof VARIABLES
 * @type {boolean}
 */
let isRunning = false;
/**
 * Is the oscilloscope triggered or not.
 * @memberof VARIABLES
 * @type {boolean}
 */
let triggered = false;
/**
 * This variable holds the amount of time spent since the trigger has last been active (ms).
 * @memberof VARIABLES
 * @type {number}
 */
let triggerClock = 0;
/**
 * This variable holds the number of errors detected while gathering data.
 * @memberof VARIABLES
 * @type {number}
 */
let failedAttempt = 0;

/**
 * When getting data from a .osc file, this variable stores the latest postion read within the file.
 * @memberof VARIABLES
 * @type {number}
 */
let currentFilePosition = 0;
/**
 * This variable stores the local access to the file we are reading.
 * @memberof VARIABLES
 * @type {string}
 */
let fileName = "NA";

const autoMeasures = calculateAutoMeasures();

const CANVAS = document.getElementById('oscilloscope_screen');
const MODAL = document.getElementById('modal');

const RUNSTOP = document.getElementById('run-stop');
const CURSORS = document.getElementById('cursors');
const DISPLAY = document.getElementById('display');
const TRIGGER = document.getElementById('trigger');
const SAVE = document.getElementById('save');
const AUTOSET = document.getElementById('autoset');
const MEASURE = document.getElementById('measure');
const PRINT = document.getElementById('print');
const SETUP = document.getElementById('setup');
const SIZE = document.getElementById('size');

/**
 * Fetches the current oscilloscope settings from the server and updates the configuration and channel data accordingly.
 *
 * This function makes a POST request to the '/oscillo/settings/' endpoint to retrieve the current settings.
 * It updates the global `config` object with the received settings and also updates the `channelData` object
 * based on the number of channels specified in the settings.
 * 
 * @memberof module:Main
 * @function getCurrentSettings
 * @returns {void}
 * @throws {Error} Throws an error if there's any problem with the XMLHttpRequest.
 */
function getCurrentSettings(){
    const Http = new XMLHttpRequest();
    Http.responseType = 'text';
    let frm = new FormData();

    frm.append("csrfmiddlewaretoken", document.getElementsByName("csrfmiddlewaretoken")[0].value);

    console.log("Getting current settings");

    Http.open("POST", '/oscillo/settings/');
    Http.send(frm);
    Http.onload = function() {
        console.log(Http.responseText);

        const settings = JSON.parse(Http.responseText);

        // update the numChannels in config
        config.mode = settings.mode
        config.numChannels = settings.channels;
        config.frequency = settings.freq;
        config.samplesPerFrame = settings.nb;
        config.voltage = parseFloat(settings.voltage);
        config.bitsPerSample = settings.bits;
        config.gridOpacity = settings.gridOpacity;

        if (settings.theme == "light"){
            config.theme = settings.theme;
            changeScreenLightMode("light");
        }

        if (config.mode == "FILE"){
            config.maxSampleValue = 16383;
            fileName = settings.file_path
            currentFilePosition = parseInt(settings.file_position, 10);
        }else if(config.mode == "REAL-TIME"){
            config.maxSampleValue = 65535;
        }

        console.log("Updated config:", config);
       
        let startOffset = -100

        // update the channelData object now that we know the number of channels
        for (let ch = 1; ch <= config.numChannels; ch++) {
            channelData['CH' + ch] = {
                points: [],
                display: true,
                type: 'baseData',
                focused: false,
                colorDark: channelsMetaData['CH' + ch].colorDark, //channelsMetaData is passed from the backend to here via the html page
                colorLight: channelsMetaData['CH' + ch].colorLight,
                verticalOffset: 0,
                verticalScale: 1, //default value
                verticalOffsetRelativeCursorPosition: 395,
            };

            channel_button = document.getElementById('CH' + ch);
            
            channel_button.classList.remove("channel-not-displayed");
            channel_button.classList.add("channel-displayed");
            channel_button.classList.add(channelData['CH' + ch].colorDark);

            //This part assigns each channel to its own scroller for the offset.
            setScrollersEvents(ch);

            //Here we add a small offset for the signals in order to not have them all clutered in the middle at the beginning.
            document.getElementById('scroller-CH'+ch).style.top = ((CANVAS.height / 2) + startOffset - 5) + 'px';
            channelData['CH' + ch].verticalOffset = startOffset - 30;
            startOffset += 50;
        }
    }
};

/**
 * Fetches oscilloscope data from a file on the server and updates the channel data arrays with the received data.
 *
 * This function sends a GET request to the server to retrieve data from the specified file at the current file position.
 * We need to specify from which file we need the data and the current position within the file (default = 0).
 * The received data is used to populate the `channelData` arrays, and various aspects of the oscilloscope's display are updated accordingly.
 * It also handles cursor and trigger displays, as well as error handling for network issues.
 *
 * @memberof module:Main
 * @function fetchDataFromFile
 * @throws {Error} Throws an error with a code 408 if anything went wrong with the backend. 
 * @returns {void}
 */
function fetchDataFromFile(){
    // console.log("fetchDataFromFile starts");
    const Http = new XMLHttpRequest();

    const fileNameOnly = fileName.split("/").pop();

    const url = `/oscillo/dataF/${currentFilePosition}/${fileNameOnly}/`;
    Http.open("GET", url, true);
    Http.responseType = 'json';

    Http.onload = function() {
        if (Http.status === 200) {
            // console.log("JSON data received : ");
            // console.log(Http.response);

            //Here we now populate the channel data arrays with the data received
            //We know .osc files take a max amount of 4 channels
            Object.keys(channelData).forEach(key => {
                let channelNumber = parseInt(key.substring(2), 10)
                channelData[key].points = Http.response[0][channelNumber + 1];
                currentFilePosition = parseInt(Http.response[1]);
            });

            clearCanvas();
            drawGrid('rgba(128, 128, 128, 0.5)', 3);
            if (cursorOptions.isVerticalCursorOn == "true" || cursorOptions.isHorizontalCursorOn == "true"){
                drawCursors();
            }

            //HERE WE CHECK IF THE USER IS CURRENTLY ZOOMING OR NOT
            if (zoomConfig.isDrawing == true){
                drawZoomRectangle();
            }

            //Here we check whether the trigger is active or not to add the trigger level cursor.
            if (triggerOptions.isTriggerOn == "on"){
                drawTriggerCursor();
            }else{
                document.getElementById("trigger-cursor").style.display = "none";
            };

            Object.keys(channelData).forEach(key => {
                //We start by checking wether or not the trigger is set and if so we check the trigger conditions to freeze or not this part of the signal.
                if (triggerOptions.isTriggerOn == "on"){
                    if (triggerOptions.triggerChannel == key){//we check the trigger options only for the channel specified in the settings.
                        triggered = triggerCheck(channelData[key].points);
                    };
                };

                //Here we generate the points array for the math signals
                if (channelData[key].type == "generatedData"){
                    generatePoints(key);
                }

                // Here we display the signal on the screen (if the button for this channel is active)
                if (channelData[key].display === true){
                    if (channelData[key].type == "generatedData" && channelData[key].operation == "fft"){
                        drawFFT(key);
                    } else {
                        drawSignal(key);
                    }
                }

                if (firstLoop){
                    if (channelData[key].points.length == 0){
                        document.getElementById(key).className = "channel-not-displayed ch-button";
                        document.getElementById("scroller-"+key).style.display = "none";
                        delete channelData[key];

                    }
                }
            });
            firstLoop = false;
        } else if (Http.status === 408){
            console.error("Backend Error" + Http.status)
        } else {
            console.error("Failed to load data, status: " + Http.status);
        }
    }

    Http.onerror = function() {
        console.error("There was a network error.");
    };
    Http.send();
};

/**
 * Fetches oscilloscope data either in real-time from a STARE card or via a mockup server like "fakestare.py".
 * 
 * This function sends a GET request to the server which will make it grab the latest data packet it can find and forward it back to the front-end.
 * The data is being sent raw, which is why we need to parse it in the second half of the function.
 * Just like with 'fetchDataFromFile', we then draw the signal we gathered from each channel and also added UI elements like the trigger cursor, measure cursors, etc..
 * 
 * We also call a function 'removeSpikes' to get rid of potential triggerpoints in a set of data which would otherwise result in one big spike on the screen.
 * 
 * @memberof module:Main
 * @function fetchRawData
 * @throws {Error} After a certain number of failed gathering attempts, the user will be warned something is wrong.
 * @returns {void}
 */
function fetchRawData(){
    const Http = new XMLHttpRequest();
    Http.responseType = 'arraybuffer';

    Http.open("GET", '/oscillo/dataR/', true);

    Http.onload = function(event) {
        if (Http.status === 200) {
            const buffer = Http.response;
            const dataView = new DataView(buffer);
            const bytesPerSample = 2; // Each sample is 2 bytes (Uint16) altough this may change depending on the server's settings !!
            const totalSamples = dataView.byteLength / bytesPerSample; // Total samples in all channels
    
            // console.log(`THis data is made up of ${totalSamples} samples`);
            // console.log("Here below should be the dataView created from the received data : ");
            // console.log(dataView);

            clearCanvas();
            drawGrid('rgba(128, 128, 128, 0.5)', 3);
            if (cursorOptions.isVerticalCursorOn == "true" || cursorOptions.isHorizontalCursorOn == "true"){
                drawCursors();
            }

            //HERE WE CHECK IF THE USER IS CURRENTLY ZOOMING OR NOT
            if (zoomConfig.isDrawing == true){
                drawZoomRectangle();
            }

            //Here we check whether the trigger is active or not to add the trigger level cursor.
            if (triggerOptions.isTriggerOn == "on"){
                drawTriggerCursor();
            }else{
                document.getElementById("trigger-cursor").style.display = "none";
            };

            // Clear the channel data before parsing the new data
            Object.keys(channelData).forEach(key => {
                channelData[key].points = [];
            });

            let max
            if (config.numChannels > 4){
                max = 4;
            }else{
                max = config.numChannels;
            }
            // Parse buffer into channel data
            for (let i = 0; i < totalSamples; i++) {
                let channelNum = (i % max) + 1;
                let channelKey = 'CH' + channelNum;
                let pointIndex = i * bytesPerSample;
                let point = dataView.getUint16(pointIndex, true);
                channelData[channelKey].points.push(point);
            }

            Object.keys(channelData).forEach(key => {
                //we have to use some sort of a filter to remove the trigger points or whatever is causing these massive spikes.
                const thresholdRatio = 3;
                channelData[key].points = removeSpikes(channelData[key].points, thresholdRatio);

                //Here we generate the points array for the math signals
                if (channelData[key].type == "generatedData"){
                    generatePoints(key);
                }

                // Here we display the signal on the screen (if the button for this channel is active)
                if (channelData[key].display === true){
                    if (channelData[key].type == "generatedData" && channelData[key].operation == "fft"){
                        drawFFT(key);
                    } else {
                        drawSignal(key);
                    }
                }
            });
        } else {
            console.error("The request failed unexpectedly ->", Http.statusText);
            failedAttempt++;
            if (failedAttempt > 60){
                showToast("There seem to be a problem with the reception of the data.", "toast-error");
                failedAttempt = 0;
            }
        }
    }

    Http.onerror = function() {
        console.log("An error occured while fetching the data");
    }

    Http.send();
};

/**
 * This function saves a user's color selection for each channel (both dark and light) and options regarding the grid.
 * 
 * We start by gathering every color the user has selected within the 'SETUP' menu.
 * Once we got every jvalues for all the channels and the grid, we make a POST request to the server to save the
 * selections for the user.
 * 
 * @memberof module:Main
 * @function saveColorChoices
 * @throws {Error} If the user is not registered, we let him know that the color choices won't be persistent unless they login / create an account.
 * @returns {void}
 */
function saveColorChoices(){
    const UID = userId;//We get this value from the html page "graph.html"

    console.log("Saving color choices for the user with id :", UID);

    //We start by gathering each values for the color inputs
    ColorChoicesDark = [];
    ColorChoicesLight = [];

    for (let i = 1; i < 11; i++){
        valueDark = document.getElementById("channelColorD-"+i).value;
        valueLight = document.getElementById("channelColorL-"+i).value;

        ColorChoicesDark.push(valueDark);
        ColorChoicesLight.push(valueLight);
    }

    gridOpacity = document.getElementById("gridOpacityInput").value;

    if (gridOpacity > 1){
        gridOpacity = 1;
    }else if (gridOpacity < 0){
        gridOpacity = 0;
    }

    //Now we send the data to the backend where we'll change the preferences.
    const Http = new XMLHttpRequest();
    const url = `/oscillo/setNewColors/${UID}/`;
    
    const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]').value;

    Http.open("POST", url, true);
    Http.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
    Http.setRequestHeader("X-CSRFToken", csrfToken);

    const data = JSON.stringify({
        ColorChoicesDark: ColorChoicesDark,
        ColorChoicesLight: ColorChoicesLight,
        gridOpacity: gridOpacity
    });

    Http.onreadystatechange = function() {
        if (Http.readyState === 4) {
            response = JSON.parse(Http.responseText);
            if (Http.status === 200) {
                console.log(response.message);
                showToast(response.message, "toast-success");
            } else {
                console.log("Error saving color choices: ", response.message);
                showToast(response.message, "toast-error");
                return;
            }
        }
    };

    Http.send(data);

    //Once we have confirmation the choices have been saved, we update each channel with their new colors.
    for (let i = 1; i < 11; i++){
        channelsMetaData["CH"+i].colorDark = ColorChoicesDark[i -1];
        channelsMetaData["CH"+i].colorLight = ColorChoicesLight[i -1];
        try {//In case we have channels not directly next to one another (eg. CH1, CH2, CH7, CH9)
            channelData["CH"+i].colorDark = ColorChoicesDark[i -1];
            channelData["CH"+i].colorLight = ColorChoicesLight[i -1];
        } catch (error) {}
    }
    config.gridOpacity = gridOpacity;

    //We finish off by changing the channel buttons' color & their offset cursor.
    Object.keys(channelData).forEach(key => {
        if (channelData[key].display == true){
            if (config.theme == "dark"){
                document.getElementById(key).className = "ch-button channel-displayed " + channelData[key].colorDark;
                document.getElementById("scroller-"+key).style.backgroundColor = channelData[key].colorDark
            }else{
                document.getElementById(key).className = "ch-button channel-displayed " + channelData[key].colorLight;
                document.getElementById("scroller-"+key).style.backgroundColor = channelData[key].colorLight
            }
        }
    });

    let timeDelay = parseInt(document.getElementById("timeDelayInput").value, 10);
    if (timeDelay > 1000){
        timeDelay = 1000;
    }else if(timeDelay < 30){
        timeDelay = 30;
    }
    loopDelay = timeDelay;
    clearInterval(LOOP);
    MAINLOOP();
};

/**
 * This function is the first one to be called once the page is fully loaded.
 * 
 * Its purpose is to make ready every single UI interaction available on the oscilloscope page.
 * This includes every function button (SETUP, DISPLAY, etc..) and channel buttons, cursors, etc ..
 * 
 * Once every listener is set on the page we finish by gathering the current settings from the backend.
 * 
 * @memberof module:Main
 * @function environmentSetup
 * @returns {void}
 * 
 * @fires changeChannelButtonStatus - When a channel button is clicked on.
 * @fires isRunning - When the RUN/STOP button is clicked on.
 * @fires downloadCanvasAsImage - When the PRINT button is clicked on.
 * @fires populateModalForSave - When the SAVE button is clicked on.
 * @fires populateModalForMeasure_MATHS - When the MEASURE button is clicked on.
 * @fires populateModalForTrigger - When the TRIGGER button is clicked on (if oscilloscope isn't triggered).
 * @fires autoset - When the AUTOSET button is clicked on.
 * @fires populateModalForCursors - When the CURSORS button is clicked on.
 * @fires populateModalForSize - When the SIZE button is clicked on.
 * @fires populateModalForDisplay - WHen the DISPLAY button is clicked on.
 * @fires populateModalForSetup - When the SETUP button is clicked on.
 * 
 * @listens horizontalCursor For a user dragging the horizontal offset cursor (triangle).
 * @listens VerticalKnob For a user interacting with the vertical scaling knob (bottom right).
 * @listens HorizontalKnob For a user interacting with the horizontal scaling knob (bottom right).
 * @listens CANVAS For a user dragging their mouse on the canvas.
 * @listens "Shift+X" Remove the zoom (if any) when user presses Shift + X.
 */
function environmentSetup(){//This function sets up anything necessary for interacting with the oscilloscope (EVentlisteners, etc)
    let isDragging = false;
    
    const verticalScalingKnob = document.getElementById("vertical-scaling");
    const horizontalScalingKnob = document.getElementById("horizontal-scaling");
    
    const scrollBarHorizontal = document.getElementById("scrollbar-horizontal");
    const scrollerHorizontal = document.getElementById("scroller-Horizontal");
    let startX;


    for (let i = 1; i < 11; i++) {//Setup listeners to change a button's aspect when clicked
        let channel = "CH" + i;
        document.getElementById(channel).addEventListener("click", function() {
            // console.log(`Channel ${channel} clicked!`);
            changeChannelButtonStatus(channel);
        });
    };

    //===================== HORIZONTAL OFFSET INTERACTIONS (MOUSE) =====================

    scrollerHorizontal.addEventListener('mousedown', function(event) {
        isDragging = true;
        startX = event.clientX - scrollerHorizontal.getBoundingClientRect().left + scrollBarHorizontal.getBoundingClientRect().left;
        document.addEventListener('mousemove', onMouseMoveHorizontal);
        document.addEventListener('mouseup', onMouseUpHorizontal);
    });

    function onMouseMoveHorizontal(event) {
        if (!isDragging) return;
        
        let newX = event.clientX - startX;
        newX = Math.max(newX, 0);
        newX = Math.min(newX, scrollBarHorizontal.clientWidth - scrollerHorizontal.clientWidth);

        scrollerHorizontal.style.left = newX + 'px';

        let percent = newX / (scrollBarHorizontal.clientWidth - scrollerHorizontal.clientWidth);
        horizontalOffset = (percent - 0.5) * 1000;
        horizontalOffset = Math.round(horizontalOffset);
    };

    function onMouseUpHorizontal(event) {
        isDragging = false;

        document.removeEventListener('mousemove', onMouseMoveHorizontal);
        document.removeEventListener('mouseup', onMouseUpHorizontal);
    };

    //===================== VERTICAL SCALING INTERACTIONS (KNOB) =====================
    verticalScalingKnob.addEventListener("input", function() {
        isDragging = true;
        Object.keys(channelData).forEach(key => {
            if (channelData[key].focused) {
                channelData[key].verticalScale = parseFloat(this.value);//convert from str to int (base10)
            }
        });
    });

    verticalScalingKnob.addEventListener("mousedown", function() {
        isDragging = false;
    });

    //Setup listener to detect a click on the knob and reinitialize the vertical scale to the default value.
    verticalScalingKnob.addEventListener("mouseup", function(){
        Object.keys(channelData).forEach(key => {
            if (channelData[key].focused && !isDragging) {
                verticalScalingKnob.value = 1;
                channelData[key].verticalScale = 1;//convert from str to int (base10)
            }
        });
        isDragging = false;
    });

    //===================== HORIZONTAL SCALING INTERACTIONS (KNOB) =====================

    horizontalScalingKnob.addEventListener("input", function(){
        isDragging = true;
        horizontalScale = parseInt(this.value);
    });

    horizontalScalingKnob.addEventListener("mousedown", function(){
        isDragging = false;
    });

    horizontalScalingKnob.addEventListener("mouseup", function(){
        if (!isDragging){
            horizontalScalingKnob.value = 50;
            horizontalScale = 50;
        }
        isDragging = false;
    });


    //===================== RUN/STOP BUTTON INTERACTIONS =====================

    RUNSTOP.addEventListener("click", function() {
        if (isRunning) {
            isRunning = false;
            RUNSTOP.innerHTML = "RUN";
            console.log("stopped the oscillo");
        } else {
            if (config.mode == null || config.mode == "NA"){
                showToast("Your settings are not set yet.\nClick on the 'Settings' button to set them up.", "toast-error");
            }else{
                //console.log("Starting the oscilloscope");
                isRunning = true;
                RUNSTOP.innerHTML = "STOP";
            }
        }
    });

    //===================== PRINT BUTTON INTERACTIONS =====================

    PRINT.addEventListener("click", function(){downloadCanvasAsImage("png")});

    //===================== SAVE BUTTON INTERACTIONS =====================
    
    SAVE.addEventListener("click", function(){
        populateModalForSave();
        displayBaseModal();
    });


    //===================== MEASURE BUTTON INTERACTIONS =====================

    MEASURE.addEventListener("click", function(){
        populateModalForMeasure_MATHS();
        displayBaseModal();
    });


    //===================== TRIGGER BUTTON INTERACTIONS =====================

    TRIGGER.addEventListener("click", function(){
        if (triggered){
            triggered = false;
        }else{
            populateModalForTrigger();
            displayBaseModal();
        }
    });

    //===================== AUTOSET BUTTON INTERACTIONS =====================

    AUTOSET.addEventListener("click", autoset);

    //===================== CURSORS BUTTON INTERACTIONS =====================

    CURSORS.addEventListener("click", function(){
        populateModalForCursors();
        displayBaseModal();
    });

    //===================== SIZE BUTTON INTERACTIONS =====================

    SIZE.addEventListener("click", function(){
        populateModalForSize();
        displayBaseModal();
    });


    //===================== SETUP CANVAS INTERACTIONS (ZOOM) =====================

    CANVAS.addEventListener('mousedown', (e) => {
        if (isRunning && zoomConfig.isZoomed == false){
            const rect = CANVAS.getBoundingClientRect();

            zoomConfig.initX = e.clientX - rect.left;
            zoomConfig.initY = e.clientY - rect.top;
            zoomConfig.isDrawing = true;
        }else{
            if (zoomConfig.isZoomed == true){
                showToast("The screen is already zoomed-in.", "toast-error");
            }else{
                showToast("Can't zoom while the oscilloscope is stopped", "toast-error");
            }
        }
    });

    CANVAS.addEventListener('mousemove', (e) => {
        if (!zoomConfig.isDrawing) return;

        const rect = CANVAS.getBoundingClientRect();
        
        zoomConfig.finalX = e.clientX - rect.left;
        zoomConfig.finalY = e.clientY - rect.top;
    });

    CANVAS.addEventListener('mouseup', () => {
        if (isRunning && zoomConfig.isDrawing && zoomConfig.isZoomed == false){
            zoomConfig.isDrawing = false;
            calculateZoomFactors();
            // console.log("Zoom config : ", zoomConfig);
    
            const ctx = CANVAS.getContext('2d');
            ctx.setTransform(zoomConfig.zoomX, 0, 0, zoomConfig.zoomY, -zoomConfig.initX * zoomConfig.zoomX, -zoomConfig.initY * zoomConfig.zoomY);
            ctx.restore();

            zoomConfig.isZoomed = true;
            showToast("Press Shift + X to exit zoomed mode", "toast-info");
        }
    });
    
    //===================== DISPLAY BUTTON INTERACTIONS =====================

    DISPLAY.addEventListener("click", function(){
        populateModalForDisplay();
        displayBaseModal();
    });

    //===================== GENERAL ZOOM RESET VIA KEY PRESS CTRL + X =====================

    document.addEventListener('keydown', function(e){
        if (e.key === "X" && e.shiftKey) {
            resetZoom();
        }
    });

    //===================== SETUP BUTTON INTERACTIONS =====================

    SETUP.addEventListener("click", function(){
        populateModalForSetup();
        displayBaseModal();
    });

    //====================== AVOID MODAL DUPLICATIONS =======================

    function preventSpaceTrigger(event) {
        if (event.key === " " || event.code === "Space") {
            event.preventDefault();
        }
    }

    const buttons = [RUNSTOP, CURSORS, DISPLAY, TRIGGER, SAVE, AUTOSET, MEASURE, PRINT, SETUP, SIZE]
    buttons.forEach(button => {
        button.addEventListener("keydown", preventSpaceTrigger);
    });

    setupTriggerCursor();

    //This part is not absolutely necessary, it justs show the grid of the screen before the oscillo has been started.
    drawGrid('rgba(128, 128, 128, 0.5)', 3);

    getCurrentSettings();
};

/**
 * This is the main function of the entire app.
 * Every X milliseconds we start by gathering data (either from file or in real-time).
 * 
 * Once the data is gathered, prepared and displayed, we set the screen information (mv/div, etc..).
 * 
 * If the oscilloscope is currently triggered, we keep the loop going and call every UI drawings 'by hand' since the functions gathering the data won't be able to do it.
 * 
* The main loop depends mainly on the following factors:
* 
* - **> Is the oscilloscope running?**
* - **> What is the active mode (file / real-time)?**
* - **> Is the oscilloscope triggered?**
* 
* At the end of every loop, we wait a few milliseconds before gathering data again to make sure there is no bottleneck happening.
* From our tests, going below 50ms for every loop will start raising errors.
* 
* @memberof module:Main
* @function MAINLOOP
* @returns {void}
*/
function MAINLOOP(){
    LOOP = setInterval(function() {
        if (config.mode != null){
            if (isRunning && !triggered){
                if (config.mode == "FILE"){
                    fetchDataFromFile();
                }else if (config.mode == "REAL-TIME"){
                    fetchRawData();
                }
                setScreenInformation();
            }else if(triggered || !isRunning){
                clearCanvas();
                drawGrid('rgba(128, 128, 128, 0.5)', 3);
                Object.keys(channelData).forEach(key => {
                    //We check here wether there could be a signal to generate from a math function that has been set during a trigger.
                    //In which case it wouldn't be drawn directly because the 'generatepoints' function is called when fetching data which we aren't when triggered.
                    if (channelData[key].type == "generatedData" && channelData[key].points.length == 0){
                        generatePoints(key);
                    };

                    if (channelData[key].display === true){
                        if (channelData[key].type == "generatedData" && channelData[key].operation == "fft"){
                            drawFFT(key);
                        }else{
                            drawSignal(key);
                            setScreenInformation();
                        }
                    };
                });
                if (cursorOptions.isVerticalCursorOn == "true" || cursorOptions.isHorizontalCursorOn == "true"){
                    drawCursors();
                }
                //Here we check whether the trigger is active or not to add the trigger level cursor.
                if (triggerOptions.isTriggerOn == "on"){
                    drawTriggerCursor();
                }else{
                    document.getElementById("trigger-cursor").style.display = "none";
                };
                if ((triggerClock / 1000) > triggerOptions.holdOff){
                    triggerClock = 0;
                    triggered = false;
                }else{
                    triggerClock = triggerClock + loopDelay;
                }
            }
        }else{
            console.log("Still waiting on settings retrieval");
        }
    }, loopDelay)

};

document.addEventListener('DOMContentLoaded', function() {
    environmentSetup();//we load all the necessary event listeners for the oscilloscope

    MAINLOOP();
});