/**
* @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();
});