Skip to content

Commit 0ab7fbf

Browse files
authoredNov 23, 2021
Implement serial plotter webapp using chart.js (#4)
* Initialize project using Create React App * first implementation * css & fonts * ws connection improvements * improved communication between components * added bourbon * removed prepare script * added action caching to speedup releases * improved dataset cleanup & data drop * offload message parsing to webworker * add serial port name and connection status * scrolling variable names * remove old dataset from chart * disable UI elements when disconnected + UI fixes to bottom panel * show cursor pointer when hovering a button
1 parent 5dfcdde commit 0ab7fbf

33 files changed

+21500
-0
lines changed
 

‎.github/workflows/publish.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
on: push
2+
3+
# push:
4+
# branches:
5+
# - main
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v2
12+
- uses: actions/setup-node@v2
13+
with:
14+
node-version: 10
15+
- uses: actions/cache@v2
16+
with:
17+
path: ~/.npm
18+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
19+
- run: npm install
20+
# TODO: enable test as soon as they are implemented
21+
# - run: npm test
22+
- run: npm run build
23+
- uses: JS-DevTools/npm-publish@v1
24+
with:
25+
token: ${{ secrets.NPM_TOKEN }}

‎.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# production
12+
/build
13+
14+
# misc
15+
.DS_Store
16+
.env.local
17+
.env.development.local
18+
.env.test.local
19+
.env.production.local
20+
21+
npm-debug.log*
22+
yarn-debug.log*
23+
yarn-error.log*

‎LICENSE.txt renamed to ‎LICENSE

File renamed without changes.

‎README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Serial Plotter WebApp
2+
3+
This is a SPA that receives data points over WebSocket and prints graphs. The purpose is to provide a visual and live representation of data printed to the Serial Port.
4+
5+
The application is designed to be as agnostic as possible regarding how and where it runs. For this reason, it accepts different settings when it's launched in order to configure the look&feel and the connection parameters.
6+
7+
8+
## Main Tech/framework used
9+
10+
- React: as the backbone of the application
11+
- Chart.js: to display data
12+
- WebSockets: to provide a fast communication mechanism between a middle layer and the Serial Plotter (see section [How it works](#how-it-works))
13+
- Npm: as the registry
14+
15+
## How it works
16+
17+
- As soon as the application is bootstrapped it reads the [URL parameters](#config-parameters) and uses them to set the initial state and create the WebSocket connection
18+
- When the WebSocket connection is created, data points are collected, parsed, and printed to the chart
19+
- The app can also send messages back to the boards via WebSocket
20+
21+
### Config Parameters
22+
23+
The Serial Plotter Web App is initialized by passing a number of parameters in the URL, in the form of a QueryString (eg: http://localhost:3000?currentBaudrate=2400&baudrates=300,1200,2400,4800,9600,19200,38400,57600,74880,115200,230400,250000,500000,1000000,2000000&darkTheme=true&wsPort=5000&connected=true&interpolate=true&generate=true).
24+
25+
| Name | Description | Type (default) |
26+
|-|-|-|
27+
| `currentBaudrate` | currently selected baudrate | Number(9600)|
28+
| `currentLineEnding` | currently selected line ending | String("\r\n")|
29+
| `baudrates` | populate the baudrates menu | String[]/Comma separated strings ([])|
30+
| `darkTheme` | whether to use the dark version of the plotter | Boolean(false) |
31+
| `wsPort` | websocket port used for communication | Number(3030) |
32+
| `interpolate` | whether to smooth the graph or not | Boolean(false) |
33+
| `serialPort` | name of the serial port the data is coming from | String("") |
34+
| `connected` | whether if the serial port is connected or not| Boolean(false) |
35+
| `generate` | generate fake datapoints to print random charts (dev purposes only)| Boolean(false) |
36+
37+
It is possible to update the state of the serial plotter by sending the above parameters via WebSocket in the form of a JSON-stringified object, using the `MIDDLEWARE_CONFIG_CHANGED` [Command](#websocket-communication-protocol).
38+
39+
### Websocket Communication Protocol
40+
41+
Besides the initial configuration, which is passed in via URL parameters, the communication between the app and the middleware is implemented over WebSocket.
42+
43+
It's possible to send a JSON-stringified message from and to the Serial Plotter App, as long as it adheres to the following format:
44+
45+
```
46+
{
47+
"command": <a valid command, see below>,
48+
"data": <the value for the command>
49+
}
50+
```
51+
52+
The command/data fields follow the specification:
53+
54+
| Command Field | Data field format | Initiator | Description |
55+
|-|-|-|-|
56+
| "PLOTTER_SET_BAUDRATE" | number | Serial Plotter | request the middleware to change the baudrate |
57+
| "PLOTTER_SET_LINE_ENDING" | string | Serial Plotter| request the middleware to change the lineending for the messages sent from the middleware to the board |
58+
| "PLOTTER_SEND_MESSAGE" | text | Serial Plotter | send a message to the middleware. The message will be sent over to the board |
59+
| "PLOTTER_SET_INTERPOLATE" | boolean | Serial Plotter | send the interpolation flag to the Middleware |
60+
| "MIDDLEWARE_CONFIG_CHANGED" | Object (see [config parameters](#config-parameters) ) | Middleware | Send an updated configuration from the middleware to the Serial Plotter. Used to update the state, eg: changing the color theme at runtime |
61+
62+
Example of a message ready to be sent from the Serial Plotter App to the Middleware
63+
64+
```typescript
65+
const websocketMessage = JSON.stringify({command: "PLOTTER_SET_BAUDRATE", data: 9600})
66+
```
67+
68+
**NOTE: For performance sake, the raw data coming from the serial port that is sent by the middleware to the serial plotter has to be a stringified array of values, rather than the Command/Data object**
69+
70+
71+
## Development
72+
73+
- `npm i` to install dependencies
74+
- `npm start` to run the application in development mode @ [http://localhost:3000](http://localhost:3000)
75+
76+
## Deployment
77+
78+
Usually, there is no need to build the app manually: as soon as a new version of the `package.json` is merged into `main` branch, the CI runs and deploys the package to npm.
79+
80+
## Security
81+
82+
If you think you found a vulnerability or other security-related bug in this project, please read our [security policy](https://github.com/arduino/arduino-serial-plotter-webapp/security/policy) and report the bug to our Security Team 🛡️ Thank you!
83+
84+
e-mail contact: security@arduino.cc

‎package-lock.json

Lines changed: 19768 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"name": "arduino-serial-plotter-webapp",
3+
"version": "0.0.15",
4+
"dependencies": {},
5+
"license": "AGPL",
6+
"scripts": {
7+
"start": "react-scripts start",
8+
"build": "react-scripts build",
9+
"test": "react-scripts test",
10+
"eject": "react-scripts eject"
11+
},
12+
"files": [
13+
"build/**/*"
14+
],
15+
"eslintConfig": {
16+
"extends": [
17+
"react-app",
18+
"react-app/jest",
19+
"plugin:prettier/recommended"
20+
]
21+
},
22+
"browserslist": {
23+
"production": [
24+
">0.2%",
25+
"not dead",
26+
"not op_mini all"
27+
],
28+
"development": [
29+
"last 1 chrome version",
30+
"last 1 firefox version",
31+
"last 1 safari version"
32+
]
33+
},
34+
"devDependencies": {
35+
"@testing-library/jest-dom": "^5.14.1",
36+
"@testing-library/react": "^11.2.7",
37+
"@testing-library/user-event": "^12.8.3",
38+
"@types/jest": "^26.0.24",
39+
"@types/node": "^12.20.28",
40+
"@types/react": "^17.0.27",
41+
"@types/react-custom-scrollbars": "^4.0.9",
42+
"@types/react-dom": "^17.0.9",
43+
"@types/react-router-dom": "^5.3.1",
44+
"arduino-sass": "^3.0.1",
45+
"bourbon": "^7.0.0",
46+
"chart.js": "^3.6.0",
47+
"chartjs-adapter-luxon": "^1.1.0",
48+
"chartjs-plugin-streaming": "^2.0.0",
49+
"eslint-config-prettier": "^8.3.0",
50+
"eslint-plugin-prettier": "^4.0.0",
51+
"luxon": "^2.1.0",
52+
"node-sass": "^6.0.1",
53+
"prettier": "^2.4.1",
54+
"react": "^17.0.2",
55+
"react-chartjs-2": "^3.3.0",
56+
"react-custom-scrollbars": "^4.2.1",
57+
"react-dom": "^17.0.2",
58+
"react-scripts": "4.0.3",
59+
"react-select": "^5.1.0",
60+
"react-switch": "^6.0.0",
61+
"typescript": "^4.4.3",
62+
"web-vitals": "^1.1.2",
63+
"worker-loader": "^3.0.8"
64+
}
65+
}

‎public/index.html

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<meta name="theme-color" content="#000000" />
8+
<meta
9+
name="Arduino Serial Monitor Web App"
10+
content=""
11+
/>
12+
<!--
13+
manifest.json provides metadata used when your web app is installed on a
14+
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
15+
-->
16+
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
17+
<!--
18+
Notice the use of %PUBLIC_URL% in the tags above.
19+
It will be replaced with the URL of the `public` folder during the build.
20+
Only files inside the `public` folder can be referenced from the HTML.
21+
22+
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
23+
work correctly both with client-side routing and a non-root public URL.
24+
Learn how to configure a non-root public URL by running `npm run build`.
25+
-->
26+
<title>Connecting...</title>
27+
</head>
28+
<body>
29+
<noscript>You need to enable JavaScript to run this app.</noscript>
30+
<div id="root"></div>
31+
<!--
32+
This HTML file is a template.
33+
If you open it directly in the browser, you will see an empty page.
34+
35+
You can add webfonts, meta tags, or analytics to this file.
36+
The build step will place the bundled scripts into the <body> tag.
37+
38+
To begin the development, run `npm start` or `yarn start`.
39+
To create a production bundle, use `npm run build` or `yarn build`.
40+
-->
41+
</body>
42+
</html>

‎public/manifest.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"short_name": "Serial Plotter App",
3+
"name": "Serial Plotter App",
4+
"start_url": ".",
5+
"display": "standalone",
6+
"theme_color": "#000000",
7+
"background_color": "#ffffff"
8+
}

‎public/robots.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# https://www.robotstxt.org/robotstxt.html
2+
User-agent: *
3+
Disallow:

‎src/App.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { useEffect, useState, useCallback, useRef } from "react";
2+
import { ChartPlotter } from "./ChartPlotter";
3+
import { namedVariablesMulti } from "./fakeMessagsGenerators";
4+
import { SerialPlotter } from "./utils";
5+
6+
export default function App() {
7+
const [config, setConfig] = useState<SerialPlotter.Config | null>(null);
8+
9+
const websocket = useRef<WebSocket | null>(null);
10+
11+
const chartRef = useRef<any>();
12+
13+
const onMiddlewareMessage = useCallback(
14+
(
15+
message:
16+
| SerialPlotter.Protocol.StreamMessage
17+
| SerialPlotter.Protocol.CommandMessage
18+
) => {
19+
// if there is no command
20+
if (!SerialPlotter.Protocol.isCommandMessage(message)) {
21+
chartRef && chartRef.current && chartRef.current.addNewData(message);
22+
return;
23+
}
24+
25+
if (
26+
message.command ===
27+
SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED
28+
) {
29+
const { darkTheme, serialPort, connected } =
30+
message.data as SerialPlotter.Config;
31+
32+
let updateTitle = false;
33+
let serialNameTitle = config?.serialPort;
34+
if (typeof serialPort !== "undefined") {
35+
serialNameTitle = serialPort;
36+
updateTitle = true;
37+
}
38+
39+
let connectedTitle = connected === false ? " (disconnected)" : "";
40+
if (typeof connected !== "undefined") {
41+
connectedTitle = connected === false ? " (disconnected)" : "";
42+
updateTitle = true;
43+
}
44+
45+
if (updateTitle) {
46+
document.title = `${serialNameTitle}${connectedTitle}`;
47+
}
48+
49+
if (typeof darkTheme !== "undefined") {
50+
darkTheme
51+
? document.body.classList.add("dark")
52+
: document.body.classList.remove("dark");
53+
}
54+
setConfig((c) => ({ ...c, ...message.data }));
55+
}
56+
},
57+
[config?.serialPort]
58+
);
59+
60+
// as soon as the wsPort is set, create a websocket connection
61+
React.useEffect(() => {
62+
if (!config?.wsPort) {
63+
return;
64+
}
65+
66+
console.log(`opening ws connection on localhost:${config?.wsPort}`);
67+
websocket.current = new WebSocket(`ws://localhost:${config?.wsPort}`);
68+
websocket.current.onmessage = (res: any) => {
69+
const message: SerialPlotter.Protocol.Message = JSON.parse(res.data);
70+
onMiddlewareMessage(message);
71+
};
72+
const wsCurrent = websocket.current;
73+
74+
return () => {
75+
console.log("closing ws connection");
76+
wsCurrent.close();
77+
};
78+
// eslint-disable-next-line react-hooks/exhaustive-deps
79+
}, [config?.wsPort]);
80+
81+
// at bootstrap read params from the URL
82+
useEffect(() => {
83+
const urlParams = new URLSearchParams(window.location.search);
84+
85+
const urlSettings: SerialPlotter.Config = {
86+
currentBaudrate: parseInt(urlParams.get("currentBaudrate") || "9600"),
87+
currentLineEnding: urlParams.get("lineEnding") || "\n",
88+
baudrates: (urlParams.get("baudrates") || "")
89+
.split(",")
90+
.map((baud: string) => parseInt(baud)),
91+
darkTheme: urlParams.get("darkTheme") === "true",
92+
wsPort: parseInt(urlParams.get("wsPort") || "3030"),
93+
interpolate: urlParams.get("interpolate") === "true",
94+
serialPort: urlParams.get("serialPort") || "/serial/port/address",
95+
connected: urlParams.get("connected") === "true",
96+
generate: urlParams.get("generate") === "true",
97+
};
98+
99+
if (config === null) {
100+
onMiddlewareMessage({
101+
command: SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED,
102+
data: urlSettings,
103+
});
104+
}
105+
}, [config, onMiddlewareMessage]);
106+
107+
// If in "generate" mode, create fake data
108+
useEffect(() => {
109+
if (config?.generate) {
110+
const randomValuesInterval = setInterval(() => {
111+
const messages = namedVariablesMulti();
112+
onMiddlewareMessage(messages);
113+
}, 32);
114+
return () => {
115+
clearInterval(randomValuesInterval);
116+
};
117+
}
118+
}, [config, onMiddlewareMessage]);
119+
120+
return (
121+
(config && (
122+
<ChartPlotter config={config} ref={chartRef} websocket={websocket} />
123+
)) ||
124+
null
125+
);
126+
}

‎src/ChartPlotter.tsx

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import React, { useState, useRef, useImperativeHandle, useEffect } from "react";
2+
3+
import { Line } from "react-chartjs-2";
4+
5+
import { addDataPoints, SerialPlotter } from "./utils";
6+
import { Legend } from "./Legend";
7+
import { Chart, ChartData, ChartOptions } from "chart.js";
8+
import "chartjs-adapter-luxon";
9+
import ChartStreaming from "chartjs-plugin-streaming";
10+
11+
import { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
12+
import { MessageToBoard } from "./MessageToBoard";
13+
14+
Chart.register(ChartStreaming);
15+
16+
// eslint-disable-next-line
17+
import Worker from "worker-loader!./msgAggregatorWorker";
18+
const worker = new Worker();
19+
20+
function _Chart(
21+
{
22+
config,
23+
websocket,
24+
}: {
25+
config: SerialPlotter.Config;
26+
websocket: React.MutableRefObject<WebSocket | null>;
27+
},
28+
ref: React.ForwardedRef<any>
29+
): React.ReactElement {
30+
const chartRef = useRef<ChartJSOrUndefined<"line">>();
31+
32+
const [, setForceUpdate] = useState(0);
33+
const [pause, setPause] = useState(false);
34+
const [connected, setConnected] = useState(config.connected);
35+
const [dataPointThreshold] = useState(50);
36+
const [cubicInterpolationMode, setCubicInterpolationMode] = useState<
37+
"default" | "monotone"
38+
>(config.interpolate ? "monotone" : "default");
39+
const [initialData] = useState<ChartData<"line">>({
40+
datasets: [],
41+
});
42+
43+
const [opts, setOpts] = useState<ChartOptions<"line">>({
44+
animation: false,
45+
maintainAspectRatio: false,
46+
normalized: true,
47+
parsing: false,
48+
datasets: {
49+
line: {
50+
pointRadius: 0,
51+
pointHoverRadius: 0,
52+
},
53+
},
54+
interaction: {
55+
intersect: false,
56+
},
57+
plugins: {
58+
tooltip: {
59+
caretPadding: 9,
60+
enabled: false, // tooltips are enabled on stop only
61+
bodyFont: {
62+
family: "Open Sans",
63+
},
64+
titleFont: {
65+
family: "Open Sans",
66+
},
67+
},
68+
decimation: {
69+
enabled: true,
70+
algorithm: "min-max",
71+
},
72+
legend: {
73+
display: false,
74+
},
75+
},
76+
elements: {
77+
line: {
78+
tension: 0, // disables bezier curves
79+
},
80+
},
81+
scales: {
82+
y: {
83+
grid: {
84+
color: config.darkTheme ? "#2C353A" : "#ECF1F1",
85+
},
86+
ticks: {
87+
color: config.darkTheme ? "#DAE3E3" : "#2C353A",
88+
font: {
89+
family: "Open Sans",
90+
},
91+
},
92+
grace: "5%",
93+
},
94+
x: {
95+
grid: {
96+
color: config.darkTheme ? "#2C353A" : "#ECF1F1",
97+
},
98+
display: true,
99+
ticks: {
100+
font: {
101+
family: "Open Sans",
102+
},
103+
color: config.darkTheme ? "#DAE3E3" : "#2C353A",
104+
count: 5,
105+
callback: (value) => {
106+
return parseInt(value.toString(), 10);
107+
},
108+
align: "center",
109+
},
110+
type: "linear",
111+
bounds: "data",
112+
113+
// Other notable settings:
114+
// type: "timeseries"
115+
// time: {
116+
// unit: "second",
117+
// stepSize: 1,
118+
// },
119+
120+
// type: "realtime"
121+
// duration: 10000,
122+
// delay: 500,
123+
},
124+
},
125+
});
126+
127+
useEffect(() => {
128+
if (!config.connected) {
129+
setConnected(false);
130+
return;
131+
}
132+
133+
// when the connection becomes connected, need to cleanup the previous state
134+
if (!connected && config.connected) {
135+
// cleanup buffer state
136+
worker.postMessage({ command: "cleanup" });
137+
setConnected(true);
138+
}
139+
}, [config.connected, connected]);
140+
141+
const togglePause = (newState: boolean) => {
142+
if (newState === pause) {
143+
return;
144+
}
145+
if (opts.scales!.x?.type === "realtime") {
146+
(chartRef.current as any).options.scales.x.realtime.pause = pause;
147+
}
148+
setPause(newState);
149+
(opts.plugins as any).tooltip.enabled = newState;
150+
opts.datasets!.line!.pointHoverRadius = newState ? 3 : 0;
151+
setOpts(opts);
152+
};
153+
154+
const setInterpolate = (interpolate: boolean) => {
155+
const newCubicInterpolationMode = interpolate ? "monotone" : "default";
156+
157+
if (chartRef && chartRef.current) {
158+
for (let i = 0; i < chartRef.current.data.datasets.length; i++) {
159+
const dataset = chartRef.current.data.datasets[i];
160+
if (dataset) {
161+
dataset.cubicInterpolationMode = newCubicInterpolationMode;
162+
}
163+
}
164+
chartRef.current.update();
165+
setCubicInterpolationMode(newCubicInterpolationMode);
166+
}
167+
};
168+
169+
useImperativeHandle(ref, () => ({
170+
addNewData(data: string[]) {
171+
if (pause) {
172+
return;
173+
}
174+
// upon message receival update the chart
175+
worker.postMessage({ data });
176+
},
177+
}));
178+
179+
useEffect(() => {
180+
const addData = (event: MessageEvent<any>) => {
181+
addDataPoints(
182+
event.data,
183+
chartRef.current,
184+
opts,
185+
cubicInterpolationMode,
186+
dataPointThreshold,
187+
setForceUpdate
188+
);
189+
};
190+
worker.addEventListener("message", addData);
191+
192+
return () => {
193+
worker.removeEventListener("message", addData);
194+
};
195+
}, [cubicInterpolationMode, opts, dataPointThreshold]);
196+
197+
const wsSend = (command: string, data: string | number | boolean) => {
198+
if (websocket && websocket?.current?.readyState === WebSocket.OPEN) {
199+
websocket.current.send(
200+
JSON.stringify({
201+
command,
202+
data,
203+
})
204+
);
205+
}
206+
};
207+
208+
return (
209+
<>
210+
<div className="chart-container">
211+
<Legend
212+
chartRef={chartRef.current}
213+
pause={pause}
214+
config={config}
215+
cubicInterpolationMode={cubicInterpolationMode}
216+
wsSend={wsSend}
217+
setPause={togglePause}
218+
setInterpolate={setInterpolate}
219+
/>
220+
<div className="canvas-container">
221+
<Line data={initialData} ref={chartRef as any} options={opts} />
222+
</div>
223+
<MessageToBoard config={config} wsSend={wsSend} />
224+
</div>
225+
</>
226+
);
227+
}
228+
229+
export const ChartPlotter = React.forwardRef(_Chart);

‎src/Legend.tsx

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
3+
import { LegendItem } from "./LegendItem";
4+
import { SerialPlotter } from "./utils";
5+
import { Scrollbars } from "react-custom-scrollbars";
6+
import Switch from "react-switch";
7+
8+
export function Legend({
9+
chartRef,
10+
pause,
11+
config,
12+
cubicInterpolationMode,
13+
wsSend,
14+
setPause,
15+
setInterpolate,
16+
}: {
17+
chartRef: ChartJSOrUndefined<"line">;
18+
pause: boolean;
19+
config: SerialPlotter.Config;
20+
cubicInterpolationMode: "monotone" | "default";
21+
wsSend: (command: string, data: string | number | boolean) => void;
22+
setPause: (pause: boolean) => void;
23+
setInterpolate: (interpolate: boolean) => void;
24+
}): React.ReactElement {
25+
const scrollRef = useRef<Scrollbars>(null);
26+
27+
const [showScrollLeft, setShowScrollLeft] = useState(false);
28+
const [showScrollRight, setShowScrollRight] = useState(false);
29+
30+
const checkScrollArrows = () => {
31+
if (
32+
scrollRef.current &&
33+
scrollRef.current.getClientWidth() < scrollRef.current.getScrollWidth()
34+
) {
35+
setShowScrollLeft(true);
36+
setShowScrollRight(true);
37+
if (scrollRef.current.getScrollLeft() === 0) setShowScrollLeft(false);
38+
if (
39+
scrollRef.current.getScrollLeft() +
40+
scrollRef.current.getClientWidth() >=
41+
scrollRef.current.getScrollWidth()
42+
)
43+
setShowScrollRight(false);
44+
} else {
45+
setShowScrollLeft(false);
46+
setShowScrollRight(false);
47+
}
48+
};
49+
50+
useEffect(() => {
51+
checkScrollArrows();
52+
}, [chartRef, pause, setPause, config]);
53+
54+
useEffect(() => {
55+
window.addEventListener("resize", checkScrollArrows);
56+
return () => {
57+
window.removeEventListener("resize", checkScrollArrows);
58+
};
59+
}, []);
60+
return (
61+
<div className="legend">
62+
<div className="scroll-wrap">
63+
{showScrollLeft && (
64+
<button
65+
className="scroll-button left"
66+
onClick={() => {
67+
scrollRef.current?.scrollLeft(
68+
scrollRef.current.getScrollLeft() - 100
69+
);
70+
}}
71+
>
72+
<svg
73+
width="15"
74+
height="15"
75+
viewBox="0 0 15 15"
76+
fill="none"
77+
xmlns="http://www.w3.org/2000/svg"
78+
>
79+
<path
80+
d="M7.5 14.9375C8.971 14.9375 10.409 14.5013 11.6321 13.6841C12.8551 12.8668 13.8084 11.7052 14.3714 10.3462C14.9343 8.98718 15.0816 7.49175 14.7946 6.04902C14.5076 4.60628 13.7993 3.28105 12.7591 2.24089C11.719 1.20074 10.3937 0.492387 8.95098 0.205409C7.50825 -0.0815684 6.01282 0.0657188 4.65379 0.628645C3.29477 1.19157 2.13319 2.14486 1.31594 3.36795C0.498701 4.59104 0.0624998 6.029 0.0624997 7.5C0.0624995 9.47255 0.846091 11.3643 2.24089 12.7591C3.63569 14.1539 5.52745 14.9375 7.5 14.9375ZM4.99781 7.12281L8.18531 3.93531C8.2347 3.88552 8.29345 3.846 8.35819 3.81903C8.42293 3.79205 8.49237 3.77817 8.5625 3.77817C8.63263 3.77817 8.70207 3.79205 8.7668 3.81903C8.83154 3.846 8.8903 3.88552 8.93969 3.93531C8.98948 3.9847 9.029 4.04346 9.05597 4.10819C9.08294 4.17293 9.09683 4.24237 9.09683 4.3125C9.09683 4.38263 9.08294 4.45207 9.05597 4.51681C9.029 4.58154 8.98948 4.6403 8.93969 4.68969L6.12406 7.5L8.93969 10.3103C9.03972 10.4103 9.09592 10.546 9.09592 10.6875C9.09592 10.829 9.03972 10.9647 8.93969 11.0647C8.83965 11.1647 8.70397 11.2209 8.5625 11.2209C8.42102 11.2209 8.28535 11.1647 8.18531 11.0647L4.99781 7.87719C4.94802 7.8278 4.9085 7.76904 4.88152 7.70431C4.85455 7.63957 4.84067 7.57013 4.84067 7.5C4.84067 7.42987 4.85455 7.36043 4.88152 7.29569C4.9085 7.23096 4.94802 7.1722 4.99781 7.12281Z"
81+
fill="#424242"
82+
/>
83+
</svg>
84+
</button>
85+
)}
86+
<Scrollbars
87+
ref={scrollRef}
88+
className="scrollbar"
89+
renderTrackVertical={(props) => <div {...props} className="track" />}
90+
renderTrackHorizontal={(props) => (
91+
<div {...props} className="track" />
92+
)}
93+
style={{
94+
height: "29px",
95+
marginRight: "17px",
96+
marginLeft: "-5px",
97+
}}
98+
onScroll={checkScrollArrows}
99+
>
100+
<div className="chart-names">
101+
{chartRef?.data.datasets.map((dataset, i) => (
102+
<LegendItem dataset={dataset} key={i} chartRef={chartRef} />
103+
))}
104+
</div>
105+
</Scrollbars>
106+
{showScrollRight && (
107+
<button
108+
className="scroll-button right"
109+
onClick={() =>
110+
scrollRef.current?.scrollLeft(
111+
scrollRef.current.getScrollLeft() + 100
112+
)
113+
}
114+
>
115+
<svg
116+
width="15"
117+
height="15"
118+
viewBox="0 0 15 15"
119+
fill="none"
120+
xmlns="http://www.w3.org/2000/svg"
121+
>
122+
<path
123+
d="M7.5 0.0625C6.029 0.0625 4.59104 0.498702 3.36795 1.31594C2.14486 2.13319 1.19158 3.29477 0.628649 4.65379C0.0657225 6.01282 -0.0815647 7.50825 0.205413 8.95098C0.49239 10.3937 1.20074 11.719 2.2409 12.7591C3.28105 13.7993 4.60629 14.5076 6.04902 14.7946C7.49175 15.0816 8.98718 14.9343 10.3462 14.3714C11.7052 13.8084 12.8668 12.8551 13.6841 11.6321C14.5013 10.409 14.9375 8.971 14.9375 7.5C14.9375 5.52745 14.1539 3.63569 12.7591 2.24089C11.3643 0.846091 9.47255 0.0625 7.5 0.0625ZM10.0022 7.87719L6.81469 11.0647C6.7653 11.1145 6.70655 11.154 6.64181 11.181C6.57707 11.2079 6.50763 11.2218 6.4375 11.2218C6.36737 11.2218 6.29793 11.2079 6.2332 11.181C6.16846 11.154 6.1097 11.1145 6.06032 11.0647C6.01052 11.0153 5.971 10.9565 5.94403 10.8918C5.91706 10.8271 5.90317 10.7576 5.90317 10.6875C5.90317 10.6174 5.91706 10.5479 5.94403 10.4832C5.971 10.4185 6.01052 10.3597 6.06032 10.3103L8.87594 7.5L6.06032 4.68969C5.96028 4.58965 5.90408 4.45397 5.90408 4.3125C5.90408 4.17103 5.96028 4.03535 6.06032 3.93531C6.16035 3.83528 6.29603 3.77908 6.4375 3.77908C6.57898 3.77908 6.71465 3.83528 6.81469 3.93531L10.0022 7.12281C10.052 7.1722 10.0915 7.23096 10.1185 7.29569C10.1454 7.36043 10.1593 7.42987 10.1593 7.5C10.1593 7.57013 10.1454 7.63957 10.1185 7.70431C10.0915 7.76904 10.052 7.8278 10.0022 7.87719Z"
124+
fill="#2C353A"
125+
/>
126+
</svg>
127+
</button>
128+
)}
129+
</div>
130+
<div className="actions">
131+
<label className="interpolate">
132+
<span>Interpolate</span>
133+
<Switch
134+
checkedIcon={false}
135+
uncheckedIcon={false}
136+
height={20}
137+
width={37}
138+
handleDiameter={14}
139+
offColor="#C9D2D2"
140+
onColor="#008184"
141+
onChange={(val) => {
142+
setInterpolate(val);
143+
144+
// send new interpolation mode to middleware
145+
wsSend(
146+
SerialPlotter.Protocol.Command.PLOTTER_SET_INTERPOLATE,
147+
val
148+
);
149+
}}
150+
checked={cubicInterpolationMode === "monotone"}
151+
/>
152+
</label>
153+
<button
154+
disabled={!config.connected}
155+
className="pause-button"
156+
title={config.connected ? undefined : "Serial disconnected"}
157+
onClick={() => {
158+
if (!config.connected) return;
159+
setPause(!pause);
160+
}}
161+
>
162+
{((pause || !config.connected) && "RUN") || "STOP"}
163+
</button>
164+
<button
165+
className="clear-button"
166+
onClick={() => {
167+
if (chartRef && Array.isArray(chartRef.data.datasets)) {
168+
for (let dataI in chartRef?.data.datasets) {
169+
chartRef.data.datasets[dataI].data = [];
170+
}
171+
}
172+
chartRef?.update();
173+
}}
174+
>
175+
<svg
176+
width="24"
177+
height="24"
178+
viewBox="0 0 24 24"
179+
fill="none"
180+
xmlns="http://www.w3.org/2000/svg"
181+
>
182+
<path
183+
d="M20.25 10.5H13.5C13.3011 10.5 13.1103 10.421 12.9697 10.2803C12.829 10.1397 12.75 9.94891 12.75 9.75C12.75 9.55109 12.829 9.36032 12.9697 9.21967C13.1103 9.07902 13.3011 9 13.5 9H20.25C20.4489 9 20.6397 9.07902 20.7803 9.21967C20.921 9.36032 21 9.55109 21 9.75C21 9.94891 20.921 10.1397 20.7803 10.2803C20.6397 10.421 20.4489 10.5 20.25 10.5Z"
184+
fill="#4E5B61"
185+
/>
186+
<path
187+
d="M20.25 6H13.5C13.3011 6 13.1103 5.92098 12.9697 5.78033C12.829 5.63968 12.75 5.44891 12.75 5.25C12.75 5.05109 12.829 4.86032 12.9697 4.71967C13.1103 4.57902 13.3011 4.5 13.5 4.5H20.25C20.4489 4.5 20.6397 4.57902 20.7803 4.71967C20.921 4.86032 21 5.05109 21 5.25C21 5.44891 20.921 5.63968 20.7803 5.78033C20.6397 5.92098 20.4489 6 20.25 6Z"
188+
fill="#4E5B61"
189+
/>
190+
<path
191+
d="M20.25 15H3.75C3.55109 15 3.36032 14.921 3.21967 14.7803C3.07902 14.6397 3 14.4489 3 14.25C3 14.0511 3.07902 13.8603 3.21967 13.7197C3.36032 13.579 3.55109 13.5 3.75 13.5H20.25C20.4489 13.5 20.6397 13.579 20.7803 13.7197C20.921 13.8603 21 14.0511 21 14.25C21 14.4489 20.921 14.6397 20.7803 14.7803C20.6397 14.921 20.4489 15 20.25 15Z"
192+
fill="#4E5B61"
193+
/>
194+
<path
195+
d="M20.25 19.5H3.75C3.55109 19.5 3.36032 19.421 3.21967 19.2803C3.07902 19.1397 3 18.9489 3 18.75C3 18.5511 3.07902 18.3603 3.21967 18.2197C3.36032 18.079 3.55109 18 3.75 18H20.25C20.4489 18 20.6397 18.079 20.7803 18.2197C20.921 18.3603 21 18.5511 21 18.75C21 18.9489 20.921 19.1397 20.7803 19.2803C20.6397 19.421 20.4489 19.5 20.25 19.5Z"
196+
fill="#4E5B61"
197+
/>
198+
<path
199+
d="M10.2829 9.9674C10.3532 10.0371 10.409 10.1201 10.4471 10.2115C10.4852 10.3029 10.5048 10.4009 10.5048 10.4999C10.5048 10.5989 10.4852 10.6969 10.4471 10.7883C10.409 10.8797 10.3532 10.9627 10.2829 11.0324C10.2132 11.1027 10.1303 11.1585 10.0389 11.1966C9.94748 11.2346 9.84945 11.2542 9.75044 11.2542C9.65143 11.2542 9.5534 11.2346 9.46201 11.1966C9.37062 11.1585 9.28766 11.1027 9.21794 11.0324L6.75044 8.5649L4.28294 11.0324C4.21322 11.1027 4.13027 11.1585 4.03888 11.1966C3.94748 11.2346 3.84945 11.2542 3.75044 11.2542C3.65143 11.2542 3.5534 11.2346 3.46201 11.1966C3.37062 11.1585 3.28766 11.1027 3.21794 11.0324C3.14765 10.9627 3.09185 10.8797 3.05377 10.7883C3.0157 10.6969 2.99609 10.5989 2.99609 10.4999C2.99609 10.4009 3.0157 10.3029 3.05377 10.2115C3.09185 10.1201 3.14765 10.0371 3.21794 9.9674L5.68544 7.4999L3.21794 5.0324C3.07671 4.89117 2.99737 4.69962 2.99737 4.4999C2.99737 4.30017 3.07671 4.10862 3.21794 3.96739C3.35917 3.82617 3.55072 3.74683 3.75044 3.74683C3.95017 3.74683 4.14171 3.82617 4.28294 3.96739L6.75044 6.4349L9.21794 3.96739C9.28787 3.89747 9.37089 3.842 9.46226 3.80415C9.55362 3.76631 9.65155 3.74683 9.75044 3.74683C9.84934 3.74683 9.94726 3.76631 10.0386 3.80415C10.13 3.842 10.213 3.89747 10.2829 3.96739C10.3529 4.03732 10.4083 4.12034 10.4462 4.21171C10.484 4.30307 10.5035 4.401 10.5035 4.4999C10.5035 4.59879 10.484 4.69672 10.4462 4.78808C10.4083 4.87945 10.3529 4.96247 10.2829 5.0324L7.81544 7.4999L10.2829 9.9674Z"
200+
fill="#4E5B61"
201+
/>
202+
</svg>
203+
</button>
204+
</div>
205+
</div>
206+
);
207+
}

‎src/LegendItem.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ChartDataset } from "chart.js";
2+
import React, { useState, CSSProperties } from "react";
3+
import { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
4+
5+
import checkmark from "./images/checkmark.svg";
6+
7+
export function LegendItem({
8+
dataset,
9+
chartRef,
10+
}: {
11+
dataset: ChartDataset<"line">;
12+
chartRef: ChartJSOrUndefined<"line">;
13+
}): React.ReactElement {
14+
const [visible, setVisible] = useState(!dataset.hidden);
15+
16+
if (!dataset) {
17+
return <></>;
18+
}
19+
20+
const bgColor = visible ? dataset.borderColor!.toString() : "";
21+
const style: CSSProperties = {
22+
backgroundColor: bgColor,
23+
borderColor: dataset.borderColor!.toString(),
24+
};
25+
26+
return (
27+
<label
28+
onClick={() => {
29+
if (visible) {
30+
dataset.hidden = true;
31+
setVisible(false);
32+
} else {
33+
dataset.hidden = false;
34+
setVisible(true);
35+
}
36+
chartRef?.update();
37+
}}
38+
>
39+
<span style={style} className="checkbox">
40+
{visible && <img src={checkmark} alt="" />}
41+
</span>
42+
{dataset?.label}
43+
</label>
44+
);
45+
}

‎src/MessageToBoard.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, { useEffect, useState } from "react";
2+
import Select from "react-select";
3+
import { SerialPlotter } from "./utils";
4+
5+
export function MessageToBoard({
6+
config,
7+
wsSend,
8+
}: {
9+
config: SerialPlotter.Config;
10+
wsSend: (command: string, data: string | number | boolean) => void;
11+
}): React.ReactElement {
12+
const [message, setMessage] = useState("");
13+
14+
const [baudRate, setBaudrate] = useState(config.currentBaudrate);
15+
const [lineEnding, setLineEnding] = useState(config.currentLineEnding);
16+
const [disabled, setDisabled] = useState(!config.connected);
17+
18+
useEffect(() => {
19+
setBaudrate(config.currentBaudrate);
20+
}, [config.currentBaudrate]);
21+
22+
useEffect(() => {
23+
setLineEnding(config.currentLineEnding);
24+
}, [config.currentLineEnding]);
25+
26+
useEffect(() => {
27+
setDisabled(!config.connected);
28+
}, [config.connected]);
29+
30+
const lineendings = [
31+
{ value: "", label: "No Line Ending" },
32+
{ value: "\n", label: "New Line" },
33+
{ value: "\r", label: "Carriage Return" },
34+
{ value: "\r\n", label: "Both NL & CR" },
35+
];
36+
37+
const baudrates = config.baudrates.map((baud) => ({
38+
value: baud,
39+
label: `${baud} baud`,
40+
}));
41+
42+
return (
43+
<div className="message-to-board">
44+
<form
45+
className="message-container"
46+
onSubmit={(e) => {
47+
wsSend(
48+
SerialPlotter.Protocol.Command.PLOTTER_SEND_MESSAGE,
49+
message + lineEnding
50+
);
51+
setMessage("");
52+
e.preventDefault();
53+
e.stopPropagation();
54+
}}
55+
>
56+
<input
57+
className="message-to-board-input"
58+
type="text"
59+
disabled={disabled}
60+
value={message}
61+
onChange={(event) => setMessage(event.target.value)}
62+
placeholder="Type Message"
63+
/>
64+
<button
65+
type="submit"
66+
className={"message-to-board-send-button"}
67+
disabled={message.length === 0 || disabled}
68+
>
69+
Send
70+
</button>
71+
72+
<Select
73+
className="singleselect lineending"
74+
classNamePrefix="select"
75+
isDisabled={disabled}
76+
value={
77+
lineendings[lineendings.findIndex((l) => l.value === lineEnding)]
78+
}
79+
name="lineending"
80+
options={lineendings}
81+
menuPlacement="top"
82+
onChange={(event) => {
83+
if (event) {
84+
setLineEnding(event.value);
85+
wsSend(
86+
SerialPlotter.Protocol.Command.PLOTTER_SET_LINE_ENDING,
87+
event.value
88+
);
89+
}
90+
}}
91+
/>
92+
</form>
93+
94+
<div>
95+
<div className="baud">
96+
<Select
97+
className="singleselect"
98+
classNamePrefix="select"
99+
isDisabled={disabled}
100+
value={baudrates[baudrates.findIndex((b) => b.value === baudRate)]}
101+
name="baudrate"
102+
options={baudrates}
103+
menuPlacement="top"
104+
onChange={(val) => {
105+
if (val) {
106+
setBaudrate(val.value);
107+
wsSend(
108+
SerialPlotter.Protocol.Command.PLOTTER_SET_BAUDRATE,
109+
val.value
110+
);
111+
}
112+
}}
113+
/>
114+
</div>
115+
</div>
116+
</div>
117+
);
118+
}

‎src/fakeMessagsGenerators.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// format1: <value1> <value2> <value3>
2+
export const generateRandomMessages = () => {
3+
const messages: string[] = [];
4+
5+
for (let i = 0; i < 1; i++) {
6+
const variables = [];
7+
for (let j = 1; j < 9; j++) {
8+
// generate serie name
9+
variables.push(`${Math.floor(Math.random() * 10)}`);
10+
}
11+
let line = variables.join(" ");
12+
13+
messages.push(line + "\r\n");
14+
}
15+
16+
return messages;
17+
};
18+
19+
const genNamedVarValPair = (i: number) => {
20+
const name = `name ${i}`;
21+
const val = `${Math.floor(Math.random() * 10)}`;
22+
return `${name}:${val}`;
23+
};
24+
25+
// format2: name1:<value1>,name2:<value2>,name3:<value3>
26+
export const namedVariables = () => {
27+
const messages: string[] = [];
28+
29+
for (let i = 1; i <= 7; i++) {
30+
let pair = genNamedVarValPair(i);
31+
messages.push(pair);
32+
}
33+
return [messages.join(",") + "\r\n"];
34+
};
35+
36+
export const namedVariablesMulti = () => {
37+
const messages: string[] = [];
38+
39+
for (let i = 1; i <= 30; i++) {
40+
messages.push(...namedVariables());
41+
}
42+
return messages;
43+
};
44+
45+
function* variableIdexes(): Generator<number[]> {
46+
let index = 0;
47+
while (true) {
48+
index++;
49+
if (index > 9) {
50+
yield [1, 3];
51+
} else {
52+
yield [1, 2, 3];
53+
}
54+
}
55+
}
56+
57+
// alternates
58+
// name1:<value1>,name2:<value2>,name3:<value3>
59+
// and
60+
// name1:<value1>,name3:<value3>
61+
// every 10 messages
62+
const iterator = variableIdexes();
63+
export const jumpyNamedVariables = () => {
64+
const messages: string[] = [];
65+
66+
for (let i of iterator.next().value) {
67+
let pair = genNamedVarValPair(i);
68+
messages.push(pair);
69+
}
70+
return [messages.join(",") + "\r\n"];
71+
};

‎src/fonts/open-sans-300.woff

20.2 KB
Binary file not shown.

‎src/fonts/open-sans-300.woff2

16.3 KB
Binary file not shown.

‎src/fonts/open-sans-500.woff

20.2 KB
Binary file not shown.

‎src/fonts/open-sans-500.woff2

16.4 KB
Binary file not shown.

‎src/fonts/open-sans-600.woff

20.1 KB
Binary file not shown.

‎src/fonts/open-sans-600.woff2

16.3 KB
Binary file not shown.

‎src/fonts/open-sans-regular.woff

20.2 KB
Binary file not shown.

‎src/fonts/open-sans-regular.woff2

16.3 KB
Binary file not shown.

‎src/images/checkmark.svg

Lines changed: 3 additions & 0 deletions
Loading

‎src/index.scss

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
@import '~arduino-sass/src/variables';
2+
@import '~arduino-sass/src/fonts';
3+
4+
/* open-sans-300 - latin */
5+
@font-face {
6+
font-family: 'Open Sans';
7+
font-style: normal;
8+
font-weight: 300;
9+
src: local(''),
10+
url('./fonts/open-sans-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
11+
url('./fonts/open-sans-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
12+
}
13+
/* open-sans-regular - latin */
14+
@font-face {
15+
font-family: 'Open Sans';
16+
font-style: normal;
17+
font-weight: 400;
18+
src: local(''),
19+
url('./fonts/open-sans-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
20+
url('./fonts/open-sans-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
21+
}
22+
/* open-sans-500 - latin */
23+
@font-face {
24+
font-family: 'Open Sans';
25+
font-style: normal;
26+
font-weight: 500;
27+
src: local(''),
28+
url('./fonts/open-sans-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
29+
url('./fonts/open-sans-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
30+
}
31+
/* open-sans-600 - latin */
32+
@font-face {
33+
font-family: 'Open Sans';
34+
font-style: normal;
35+
font-weight: 600;
36+
src: local(''),
37+
url('./fonts/open-sans-600.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
38+
url('./fonts/open-sans-600.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
39+
}
40+
41+
* {
42+
font-family: 'Open Sans';
43+
}
44+
45+
body {
46+
--bg-rga: 255, 255, 255;
47+
--text-color: #{$charcoal};
48+
--message-to-board-bg: #{$clouds};
49+
--axis-labels-color: #{$charcoal};
50+
--axis-grid-color: #{$clouds};
51+
--inputtext-bg-color: #{$white};
52+
--inputtext-border-color: #{$silver};
53+
--select-option-selected: #{$feather};
54+
--select-option-focused: #{$clouds};
55+
}
56+
57+
body.dark {
58+
--bg-rga: 23, 30, 33;
59+
--text-color: #{$fog};
60+
--message-to-board-bg: #{$onyx};
61+
--axis-labels-color: #{$fog};
62+
--axis-grid-color: #{$charcoal};
63+
--inputtext-bg-color: #{$charcoal};
64+
--inputtext-border-color: #{$jet};
65+
--select-option-selected: #{$charcoal};
66+
--select-option-focused: #{$dust};
67+
}
68+
69+
body {
70+
--chart-bg: rgba(var(--bg-rga), 1);
71+
color: var(--text-color);
72+
background-color: var(--chart-bg);
73+
margin: 0;
74+
}
75+
76+
.chart-container {
77+
display: flex;
78+
flex-direction: column;
79+
height: 100vh;
80+
81+
.message-to-board {
82+
background-color: var(--message-to-board-bg);
83+
display: flex;
84+
flex-shrink: 0;
85+
justify-content: space-between;
86+
height: 68px;
87+
margin-top: 10px;
88+
padding: 0 20px;
89+
align-items: center;
90+
91+
.message-container {
92+
display: flex;
93+
}
94+
95+
.message-to-board-input {
96+
width: 205px;
97+
}
98+
99+
.message-to-board-send-button {
100+
width: 45px;
101+
}
102+
}
103+
104+
.legend {
105+
display: flex;
106+
justify-content: space-between;
107+
margin: 10px 25px 10px 32px;
108+
align-items: center;
109+
110+
.scroll-wrap {
111+
display: inline-flex;
112+
flex: 1;
113+
margin-right: 20px;
114+
position: relative;
115+
height: 29px;
116+
overflow: hidden;
117+
scroll-behavior: smooth;
118+
align-items: center;
119+
120+
.scrollbar {
121+
div {
122+
-ms-overflow-style: none;
123+
scrollbar-width: none;
124+
scroll-behavior: smooth;
125+
bottom: -10px;
126+
margin-bottom: 16px;
127+
128+
&::-webkit-scrollbar {
129+
display: none;
130+
}
131+
}
132+
}
133+
134+
div {
135+
display: flex;
136+
align-items: center;
137+
}
138+
139+
.chart-names {
140+
white-space: nowrap;
141+
142+
label:first-child {
143+
margin-left: 15px;
144+
}
145+
}
146+
147+
.scroll-button {
148+
border: none;
149+
background: none;
150+
box-shadow: none;
151+
padding: 0;
152+
height: 25px;
153+
width: 35px;
154+
display: flex;
155+
align-items: center;
156+
157+
&.left {
158+
text-align: left;
159+
background: linear-gradient(90deg, var(--chart-bg) 60%, rgba(0, 0, 0, 0) 100%);
160+
z-index: 1;
161+
position: absolute;
162+
padding-left: 10px;
163+
left: 0;
164+
width: 45px;
165+
}
166+
&.right {
167+
flex-direction: row-reverse;
168+
text-align: right;
169+
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, var(--chart-bg) 40%);
170+
z-index: 1;
171+
position: absolute;
172+
right: 0;
173+
}
174+
175+
svg {
176+
path { fill: var(--text-color); }
177+
}
178+
}
179+
}
180+
181+
182+
183+
label {
184+
user-select: none;
185+
margin-right: 16px ;
186+
}
187+
.checkbox {
188+
display: inline-block;
189+
vertical-align: middle;
190+
width: 16px;
191+
height: 16px;
192+
border: 1px solid;
193+
box-sizing: border-box;
194+
border-radius: 2px;
195+
color: $white;
196+
text-align: center;
197+
line-height: 13px;
198+
background-color: var(--chart-bg);
199+
margin-right: 5px ;
200+
201+
img {
202+
width: 10px;
203+
}
204+
}
205+
206+
.actions {
207+
display: inline-flex;
208+
209+
.interpolate {
210+
display: flex;
211+
align-items: center;
212+
213+
span {
214+
margin-right: 10px;
215+
font-size: 14px;
216+
}
217+
}
218+
219+
.clear-button {
220+
border: none;
221+
background: none;
222+
box-shadow: none;
223+
padding: 0;
224+
margin-left: 20px;
225+
226+
svg {
227+
path { fill: var(--text-color); }
228+
}
229+
}
230+
}
231+
}
232+
233+
.canvas-container {
234+
flex: 1;
235+
position: relative;
236+
height: 50vh;
237+
padding-left: 15px;
238+
padding-right: 5px;
239+
cursor: crosshair;
240+
}
241+
242+
}
243+
244+
245+
246+
.baud,
247+
.lineending {
248+
display: inline-block;
249+
margin-left: 12px;
250+
}
251+
252+
.lineending {
253+
min-width: unset;
254+
}
255+
256+
.singleselect {
257+
min-width: 155px;
258+
font-size: 12px;
259+
260+
&.select--is-disabled {
261+
opacity: 0.5;
262+
}
263+
264+
.select__control {
265+
min-height: 0;
266+
background-color: var(--inputtext-bg-color);
267+
border-color: var(--inputtext-border-color);
268+
border-radius: 1px;
269+
270+
&--is-focused {
271+
border-color: inherit;
272+
box-shadow: none;
273+
274+
&:hover {
275+
border-color: inherit;
276+
}
277+
}
278+
}
279+
280+
.select__value-container {
281+
padding-left: 4px;
282+
}
283+
284+
.select__single-value {
285+
padding: 1px 2px;
286+
color: var(--inputtext-text-color);
287+
}
288+
289+
.select__indicator-separator {
290+
display: none;
291+
}
292+
.select__indicator {
293+
padding: 4px;
294+
&:hover {
295+
color: inherit;
296+
}
297+
298+
svg {
299+
width: 15px;
300+
height: 15px;
301+
}
302+
}
303+
.select__menu-list{
304+
background-color: var(--chart-bg);
305+
border-radius: 3px;
306+
border: 1px solid var(--inputtext-border-color);
307+
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.25);
308+
}
309+
.select__option {
310+
color: var(--inputtext-text-color);
311+
312+
&--is-selected {
313+
background-color: var(--select-option-selected);
314+
font-weight: bold;
315+
}
316+
&--is-focused {
317+
background-color: var(--select-option-focused);
318+
}
319+
}
320+
}
321+
322+
323+
input:focus,
324+
select:focus,
325+
textarea:focus,
326+
button:focus {
327+
outline: none;
328+
}
329+
330+
input[type="text"]{
331+
border:none;
332+
background-image:none;
333+
background-color:transparent;
334+
-webkit-box-shadow: none;
335+
-moz-box-shadow: none;
336+
box-shadow: none;
337+
338+
color: var(--inputtext-text-color);
339+
background-color: var(--inputtext-bg-color);
340+
341+
border: 1px solid var(--inputtext-border-color);
342+
border-radius: 1px;
343+
padding: 6px 5px;
344+
&[disabled] {
345+
opacity: 0.5;
346+
}
347+
}
348+
349+
::placeholder {
350+
color: #7F8C8D !important;
351+
}
352+
353+
button {
354+
background: #B9C7C9;
355+
border: 1px solid #E1E1E1;
356+
box-sizing: border-box;
357+
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
358+
border-radius: 1px;
359+
color: $white;
360+
padding: 6px 5px;
361+
cursor: pointer;
362+
363+
&[disabled] {
364+
opacity: 0.5;
365+
}
366+
}

‎src/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from "react";
2+
import ReactDOM from "react-dom";
3+
import "./index.scss";
4+
import App from "./App";
5+
import reportWebVitals from "./reportWebVitals";
6+
7+
ReactDOM.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>,
11+
document.getElementById("root")
12+
);
13+
14+
// If you want to start measuring performance in your app, pass a function
15+
// to log results (for example: reportWebVitals(console.log))
16+
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17+
reportWebVitals();

‎src/msgAggregatorWorker.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Worker.ts
2+
// eslint-disable-next-line no-restricted-globals
3+
const ctx: Worker = self as any;
4+
5+
// Respond to message from parent thread
6+
ctx.addEventListener("message", (event) => {
7+
const { command, data } = event.data;
8+
9+
if (command === "cleanup") {
10+
buffer = "";
11+
}
12+
13+
if (data) {
14+
ctx.postMessage(parseSerialMessages(data));
15+
}
16+
});
17+
18+
let buffer = "";
19+
const separator = "\r\n";
20+
var re = new RegExp(`(${separator})`, "g");
21+
22+
export const parseSerialMessages = (
23+
messages: string[]
24+
): {
25+
datasetNames: string[];
26+
parsedLines: { [key: string]: number }[];
27+
} => {
28+
//add any leftover from the buffer to the first line
29+
const messagesAndBuffer = (buffer + messages.join(""))
30+
.split(re)
31+
.filter((message) => message.length > 0);
32+
33+
// remove the previous buffer
34+
buffer = "";
35+
// check if the last message contains the delimiter, if not, it's an incomplete string that needs to be added to the buffer
36+
if (messagesAndBuffer[messagesAndBuffer.length - 1] !== separator) {
37+
buffer = messagesAndBuffer[messagesAndBuffer.length - 1];
38+
messagesAndBuffer.splice(-1);
39+
}
40+
41+
const datasetNames: { [key: string]: boolean } = {};
42+
const parsedLines: { [key: string]: number }[] = [];
43+
44+
// for each line, explode variables
45+
messagesAndBuffer
46+
.filter((message) => message !== separator)
47+
.forEach((message) => {
48+
const parsedLine: { [key: string]: number } = {};
49+
50+
//there are two supported formats:
51+
// format1: <value1> <value2> <value3>
52+
// format2: name1:<value1>,name2:<value2>,name3:<value3>
53+
54+
// if we find a colon, we assume the latter is being used
55+
let tokens: string[] = [];
56+
if (message.indexOf(":") > 0) {
57+
message.split(",").forEach((keyValue: string) => {
58+
let [key, value] = keyValue.split(":");
59+
key = key && key.trim();
60+
value = value && value.trim();
61+
if (key && key.length > 0 && value && value.length > 0) {
62+
tokens.push(...[key, value]);
63+
}
64+
});
65+
} else {
66+
// otherwise they are spaces
67+
const values = message.split(/\s/);
68+
values.forEach((value, i) => {
69+
if (value.length) {
70+
tokens.push(...[`value ${i + 1}`, value]);
71+
}
72+
});
73+
}
74+
75+
for (let i = 0; i < tokens.length - 1; i = i + 2) {
76+
const varName = tokens[i];
77+
const varValue = parseFloat(tokens[i + 1]);
78+
79+
//skip line endings
80+
if (varName.length === 0) {
81+
continue;
82+
}
83+
84+
// add the variable to the map of variables
85+
datasetNames[varName] = true;
86+
87+
parsedLine[varName] = varValue;
88+
}
89+
parsedLines.push(parsedLine);
90+
});
91+
92+
return { parsedLines, datasetNames: Object.keys(datasetNames) };
93+
};
94+
95+
export {};

‎src/react-app-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="react-scripts" />

‎src/reportWebVitals.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ReportHandler } from "web-vitals";
2+
3+
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4+
if (onPerfEntry && onPerfEntry instanceof Function) {
5+
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6+
getCLS(onPerfEntry);
7+
getFID(onPerfEntry);
8+
getFCP(onPerfEntry);
9+
getLCP(onPerfEntry);
10+
getTTFB(onPerfEntry);
11+
});
12+
}
13+
};
14+
15+
export default reportWebVitals;

‎src/setupTests.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// jest-dom adds custom jest matchers for asserting on DOM nodes.
2+
// allows you to do things like:
3+
// expect(element).toHaveTextContent(/react/i)
4+
// learn more: https://github.com/testing-library/jest-dom
5+
import "@testing-library/jest-dom";

‎src/typings/custom.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare module "worker-loader!*" {
2+
class WebpackWorker extends Worker {
3+
constructor();
4+
}
5+
6+
export default WebpackWorker;
7+
}

‎src/utils.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ChartDataset, ChartOptions } from "chart.js";
2+
import { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
3+
4+
export namespace SerialPlotter {
5+
export type Config = {
6+
currentBaudrate: number;
7+
currentLineEnding: string;
8+
baudrates: number[];
9+
darkTheme: boolean;
10+
wsPort: number;
11+
interpolate: boolean;
12+
serialPort: string;
13+
connected: boolean;
14+
generate?: boolean;
15+
};
16+
export namespace Protocol {
17+
export enum Command {
18+
PLOTTER_SET_BAUDRATE = "PLOTTER_SET_BAUDRATE",
19+
PLOTTER_SET_LINE_ENDING = "PLOTTER_SET_LINE_ENDING",
20+
PLOTTER_SET_INTERPOLATE = "PLOTTER_SET_INTERPOLATE",
21+
PLOTTER_SEND_MESSAGE = "PLOTTER_SEND_MESSAGE",
22+
MIDDLEWARE_CONFIG_CHANGED = "MIDDLEWARE_CONFIG_CHANGED",
23+
}
24+
export type CommandMessage = {
25+
command: SerialPlotter.Protocol.Command;
26+
data?: any;
27+
};
28+
export type StreamMessage = string[];
29+
export type Message = CommandMessage | StreamMessage;
30+
31+
export function isCommandMessage(
32+
msg: CommandMessage | StreamMessage
33+
): msg is CommandMessage {
34+
return (msg as CommandMessage).command !== undefined;
35+
}
36+
}
37+
}
38+
39+
const lineColors = [
40+
"#0072B2",
41+
"#D55E00",
42+
"#009E73",
43+
"#E69F00",
44+
"#CC79A7",
45+
"#56B4E9",
46+
"#F0E442",
47+
"#95A5A6",
48+
];
49+
50+
let existingDatasetsMap: {
51+
[key: string]: ChartDataset<"line">;
52+
} = {};
53+
54+
export const resetExistingDatasetsMap = () => {
55+
existingDatasetsMap = {};
56+
};
57+
export const resetDatapointCounter = () => {
58+
datapointCounter = 0;
59+
};
60+
61+
export let datapointCounter = 0;
62+
63+
export const addDataPoints = (
64+
parsedMessages: {
65+
datasetNames: string[];
66+
parsedLines: { [key: string]: number }[];
67+
},
68+
chart: ChartJSOrUndefined,
69+
opts: ChartOptions<"line">,
70+
cubicInterpolationMode: "default" | "monotone",
71+
dataPointThreshold: number,
72+
setForceUpdate: React.Dispatch<any>
73+
) => {
74+
if (!chart) {
75+
return;
76+
}
77+
78+
// if the chart has been crated, can add data to it
79+
if (chart && chart.data.datasets) {
80+
const { datasetNames, parsedLines } = parsedMessages;
81+
82+
const existingDatasetNames = Object.keys(existingDatasetsMap);
83+
84+
// add missing datasets to the chart
85+
existingDatasetNames.length < 8 &&
86+
datasetNames.forEach((datasetName) => {
87+
if (!existingDatasetNames.includes(datasetName)) {
88+
const newDataset = {
89+
data: [],
90+
label: datasetName,
91+
borderColor: lineColors[existingDatasetNames.length],
92+
backgroundColor: lineColors[existingDatasetNames.length],
93+
borderWidth: 1,
94+
pointRadius: 0,
95+
cubicInterpolationMode,
96+
};
97+
98+
existingDatasetsMap[datasetName] = newDataset;
99+
chart.data.datasets.push(newDataset);
100+
existingDatasetNames.push(datasetName);
101+
102+
// used to force a re-render in the parent component
103+
setForceUpdate(existingDatasetNames.length);
104+
}
105+
});
106+
107+
// iterate every parsedLine, adding each variable to the corrisponding variable in the dataset
108+
// if a dataset has not variable in the line, fill it with and empty value
109+
parsedLines.forEach((parsedLine) => {
110+
const xAxis =
111+
opts.scales!.x?.type === "realtime" ? Date.now() : datapointCounter++;
112+
113+
// add empty values to datasets that are missing in the parsedLine
114+
Object.keys(existingDatasetsMap).forEach((datasetName) => {
115+
const newPoint =
116+
datasetName in parsedLine
117+
? {
118+
x: xAxis,
119+
y: parsedLine[datasetName],
120+
}
121+
: null;
122+
123+
newPoint && existingDatasetsMap[datasetName].data.push(newPoint);
124+
});
125+
});
126+
127+
const oldDataValue = datapointCounter - dataPointThreshold;
128+
for (let s = 0; s < chart.data.datasets.length; s++) {
129+
const dataset = chart.data.datasets[s];
130+
131+
let delCount = 0;
132+
for (let i = 0; i < dataset.data.length; i++) {
133+
if (dataset.data[i] && (dataset.data[i] as any).x < oldDataValue) {
134+
delCount++;
135+
} else {
136+
dataset.data.splice(0, delCount);
137+
break; // go to the next dataset
138+
}
139+
140+
// purge the data if we need to remove all points
141+
if (dataset.data.length === delCount) {
142+
// remove the whole dataset from the chart and the map
143+
delete existingDatasetsMap[dataset.label!];
144+
chart.data.datasets.splice(s, 1);
145+
setForceUpdate(-1);
146+
}
147+
}
148+
}
149+
chart.update();
150+
}
151+
};

‎tsconfig.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es5",
4+
"lib": [
5+
"dom",
6+
"dom.iterable",
7+
"esnext"
8+
],
9+
"allowJs": true,
10+
"skipLibCheck": true,
11+
"esModuleInterop": true,
12+
"allowSyntheticDefaultImports": true,
13+
"strict": true,
14+
"forceConsistentCasingInFileNames": true,
15+
"noFallthroughCasesInSwitch": true,
16+
"module": "esnext",
17+
"moduleResolution": "node",
18+
"resolveJsonModule": true,
19+
"isolatedModules": true,
20+
"noEmit": true,
21+
"jsx": "react-jsx"
22+
},
23+
"include": [
24+
"src"
25+
]
26+
}

0 commit comments

Comments
 (0)
Please sign in to comment.