diff --git a/index.html b/index.html index a8f30c4..e7f3efa 100644 --- a/index.html +++ b/index.html @@ -97,8 +97,21 @@
+
+
diff --git a/js/common/plotter.js b/js/common/plotter.js new file mode 100644 index 0000000..c224b05 --- /dev/null +++ b/js/common/plotter.js @@ -0,0 +1,193 @@ +import Chart from "chart.js/auto"; + +let textLineBuffer = ""; +let textLine; + +let defaultColors = ['#8888ff', '#ff8888', '#88ff88']; + +/** + * @name LineBreakTransformer + * Helper to parse the incoming string messages into lines. + */ +class LineBreakTransformer { + constructor() { + // A container for holding stream data until a new line. + this.container = ''; + } + + transform(chunk, linesList) { + this.container += chunk; + const lines = this.container.split('\n'); + this.container = lines.pop(); + lines.forEach(line => linesList.push(line)); + } + +} + +let lineTransformer = new LineBreakTransformer() + +export function plotValues(chartObj, serialMessage, bufferSize) { + /* + Given a string serialMessage, parse it into the plottable value(s) that + it contains if any, and plot those values onto the given chartObj. If + the serialMessage doesn't represent a complete textLine it will be stored + into a buffer and combined with subsequent serialMessages until a full + textLine is formed. + */ + let currentLines = [] + lineTransformer.transform(serialMessage, currentLines) + + for (textLine of currentLines) { + + textLine = textLine.replace("\r", "").replace("\n", "") + if (textLine.length === 0) { + continue; + } + + let valuesToPlot; + + // handle possible tuple in textLine + if (textLine.startsWith("(") && textLine.endsWith(")")) { + textLine = "[" + textLine.substring(1, textLine.length - 1) + "]"; + console.log("after tuple conversion: " + textLine); + } + + // handle possible list in textLine + if (textLine.startsWith("[") && textLine.endsWith("]")) { + valuesToPlot = JSON.parse(textLine); + for (let i = 0; i < valuesToPlot.length; i++) { + valuesToPlot[i] = parseFloat(valuesToPlot[i]) + } + + } else { // handle possible CSV in textLine + valuesToPlot = textLine.split(",") + for (let i = 0; i < valuesToPlot.length; i++) { + valuesToPlot[i] = parseFloat(valuesToPlot[i]) + } + } + + if (valuesToPlot === undefined || valuesToPlot.length === 0) { + continue; + } + + try { + while (chartObj.data.labels.length > bufferSize) { + chartObj.data.labels.shift(); + for (let i = 0; i < chartObj.data.datasets.length; i++) { + while (chartObj.data.datasets[i].data.length > bufferSize) { + chartObj.data.datasets[i].data.shift(); + } + } + } + chartObj.data.labels.push(""); + + for (let i = 0; i < valuesToPlot.length; i++) { + if (isNaN(valuesToPlot[i])) { + continue; + } + if (i > chartObj.data.datasets.length - 1) { + let curColor = '#000000'; + if (i < defaultColors.length) { + curColor = defaultColors[i]; + } + chartObj.data.datasets.push({ + label: i.toString(), + data: [], + borderColor: curColor, + backgroundColor: curColor + }); + } + chartObj.data.datasets[i].data.push(valuesToPlot[i]); + } + + updatePlotterScales(chartObj); + chartObj.update(); + } catch (e) { + console.log("JSON parse error"); + // This line isn't a valid data value + } + } +} + +function updatePlotterScales(chartObj) { + /* + Update the scale of the plotter so that maximum and minimum values are sure + to be shown within the plotter instead of going outside the visible range. + */ + let allData = [] + for (let i = 0; i < chartObj.data.datasets.length; i++) { + allData = allData.concat(chartObj.data.datasets[i].data) + } + chartObj.options.scales.y.min = Math.min(...allData) - 10 + chartObj.options.scales.y.max = Math.max(...allData) + 10 +} + +export async function setupPlotterChart(workflow) { + /* + Initialize the plotter chart and configure it. + */ + let initialData = [] + Chart.defaults.backgroundColor = '#444444'; + Chart.defaults.borderColor = '#000000'; + Chart.defaults.color = '#000000'; + Chart.defaults.aspectRatio = 3/2; + workflow.plotterChart = new Chart( + document.getElementById('plotter-canvas'), + { + type: 'line', + options: { + animation: false, + scales: { + y: { + min: -1, + max: 1, + grid:{ + color: "#666" + }, + border: { + color: "#444" + } + }, + x:{ + grid: { + display: true, + color: "#666" + }, + border: { + color: "#444" + } + } + } + }, + data: { + labels: initialData.map(row => row.timestamp), + datasets: [ + { + label: '0', + data: initialData.map(row => row.value) + } + ] + } + } + ); + + // Set up a listener to respond to user changing the grid choice configuration + // dropdown + workflow.plotterGridLines.addEventListener('change', (event) => { + let gridChoice = event.target.value; + if (gridChoice === "x"){ + workflow.plotterChart.options.scales.x.grid.display = true; + workflow.plotterChart.options.scales.y.grid.display = false; + }else if (gridChoice === "y"){ + workflow.plotterChart.options.scales.y.grid.display = true; + workflow.plotterChart.options.scales.x.grid.display = false; + }else if (gridChoice === "both"){ + workflow.plotterChart.options.scales.y.grid.display = true; + workflow.plotterChart.options.scales.x.grid.display = true; + }else if (gridChoice === "none"){ + workflow.plotterChart.options.scales.y.grid.display = false; + workflow.plotterChart.options.scales.x.grid.display = false; + } + workflow.plotterChart.update(); + }); +} diff --git a/js/script.js b/js/script.js index 83ece82..3f0f1ab 100644 --- a/js/script.js +++ b/js/script.js @@ -20,6 +20,7 @@ import { ButtonValueDialog, MessageModal } from './common/dialogs.js'; import { isLocal, switchUrl, getUrlParam } from './common/utilities.js'; import { CONNTYPE } from './constants.js'; import './layout.js'; // load for side effects only +import {setupPlotterChart} from "./common/plotter.js"; import { mainContent, showSerial } from './layout.js'; // Instantiate workflows @@ -33,6 +34,7 @@ let unchanged = 0; let connectionPromise = null; const btnRestart = document.querySelector('.btn-restart'); +const btnPlotter = document.querySelector('.btn-plotter'); const btnClear = document.querySelector('.btn-clear'); const btnConnect = document.querySelectorAll('.btn-connect'); const btnNew = document.querySelectorAll('.btn-new'); @@ -42,6 +44,7 @@ const btnSaveAs = document.querySelectorAll('.btn-save-as'); const btnSaveRun = document.querySelectorAll('.btn-save-run'); const btnInfo = document.querySelector('.btn-info'); const terminalTitle = document.getElementById('terminal-title'); +const serialPlotter = document.getElementById('plotter'); const messageDialog = new MessageModal("message"); const connectionType = new ButtonValueDialog("connection-type"); @@ -130,9 +133,28 @@ btnRestart.addEventListener('click', async function(e) { // Clear Button btnClear.addEventListener('click', async function(e) { + if (workflow.plotterChart){ + workflow.plotterChart.data.datasets.forEach((dataSet, index) => { + workflow.plotterChart.data.datasets[index].data = []; + }); + workflow.plotterChart.data.labels = []; + workflow.plotterChart.options.scales.y.min = -1; + workflow.plotterChart.options.scales.y.max = 1; + workflow.plotterChart.update(); + } state.terminal.clear(); }); +// Plotter Button +btnPlotter.addEventListener('click', async function(e){ + serialPlotter.classList.toggle("hidden"); + if (!workflow.plotterEnabled){ + await setupPlotterChart(workflow); + workflow.plotterEnabled = true; + } + state.fitter.fit(); +}); + btnInfo.addEventListener('click', async function(e) { if (await checkConnected()) { await workflow.showInfo(getDocState()); diff --git a/js/workflows/workflow.js b/js/workflows/workflow.js index 42f5491..0889768 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -4,6 +4,7 @@ import {FileHelper} from '../common/file.js'; import {UnsavedDialog} from '../common/dialogs.js'; import {FileDialog, FILE_DIALOG_OPEN, FILE_DIALOG_SAVE} from '../common/file_dialog.js'; import {CONNTYPE, CONNSTATE} from '../constants.js'; +import {plotValues} from '../common/plotter.js' /* * This class will encapsulate all of the common workflow-related functions @@ -47,6 +48,8 @@ class Workflow { this._unsavedDialog = new UnsavedDialog("unsaved"); this._fileDialog = new FileDialog("files", this.showBusy.bind(this)); this.repl = new REPL(); + this.plotterEnabled = false; + this.plotterChart = false; } async init(params) { @@ -59,6 +62,8 @@ class Workflow { this._loadFileContents = params.loadFileFunc; this._showMessage = params.showMessageFunc; this.loader = document.getElementById("loader"); + this.plotterBufferSize = document.getElementById('buffer-size'); + this.plotterGridLines = document.getElementById('plot-gridlines-select'); if ("terminalTitle" in params) { this.terminalTitle = params.terminalTitle; } @@ -159,6 +164,9 @@ class Workflow { } writeToTerminal(data) { + if (this.plotterEnabled) { + plotValues(this.plotterChart, data, this.plotterBufferSize.value); + } this.terminal.write(data); } diff --git a/package-lock.json b/package-lock.json index 7397f0a..f668a9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "chart.js": "^4.4.4", "codemirror": "^6.0.1", "file-saver": "^2.0.5", "focus-trap": "^7.5.4", @@ -142,6 +143,11 @@ "node": ">=6" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@lezer/common": { "version": "1.2.1", "license": "MIT" @@ -451,6 +457,17 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "dev": true, diff --git a/package.json b/package.json index c5f7ab4..1cfa427 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "chart.js": "^4.4.4", "codemirror": "^6.0.1", "file-saver": "^2.0.5", "focus-trap": "^7.5.4", diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index a5182f3..e2fa129 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -72,6 +72,18 @@ } #serial-page { + #plotter { + flex: 2 1 0; + background: #777; + position: relative; + width: 99%; + overflow: hidden; + padding: 10px 20px; + + &.hidden{ + display: none; + } + } #terminal { flex: 1 1 0%; background: #333; @@ -99,6 +111,9 @@ } } } + #buffer-size{ + width: 70px; + } } #ble-instructions,