Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 148 additions & 23 deletions js/common/plotter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,38 @@ import Chart from "chart.js/auto";
let textLineBuffer = "";
let textLine;

let defaultColors = ['#8888ff', '#ff8888', '#88ff88'];
// Plotter color palette.
// Plotter background is #777 on dark theme and #ccc on light theme, so colors
// must be readable against mid-gray. Pale tints, gray, and brown are dropped
// for that reason. Picked from the Okabe-Ito + tab10 mid-saturation set;
// reused round-robin if a sketch sends more series than colors.
let defaultColors = [
'#1f77b4', // blue
'#ff7f0e', // orange
'#2ca02c', // green
'#d62728', // red
'#9467bd', // purple
'#e377c2', // pink
'#17becf', // cyan
'#bcbd22', // olive
'#e41a1c', // vivid red
'#377eb8', // steel blue
'#4daf4a', // leaf green
'#984ea3', // violet
];

// Resolve a CSS custom property from :root (or body, for theme overrides)
// at chart-build time. Falls back to the supplied default if the variable
// is unset or empty.
function getCssVar(name, fallback) {
if (typeof window === 'undefined' || !window.getComputedStyle) {
return fallback;
}
const value = window.getComputedStyle(document.body || document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}

/**
* @name LineBreakTransformer
Expand All @@ -26,6 +57,65 @@ class LineBreakTransformer {

let lineTransformer = new LineBreakTransformer()

/**
* Parse an Arduino Serial Plotter style line into an array of
* { label, value } pairs.
*
* The Arduino Serial Plotter accepts values separated by commas, tabs, or
* spaces, and each value may be prefixed with a label using "label:value".
* Labels are optional; positional values without a label fall back to their
* index. Examples that should all parse:
* "1,2,3"
* "1\t2\t3"
* "Temp:23.4,Hum:55.1"
* "405nm_F1:123\t425nm_F2:456\tClear:789"
*
* @param {string} textLine
* @returns {Array<{label: (string|null), value: number}>}
*/
function parseLabeledValues(textLine) {
// Split on commas, tabs, or runs of spaces. Arduino's plotter is lenient
// about which of these the sketch picks.
const tokens = textLine.split(/[,\t]|\s+/).filter(t => t.length > 0);
const parsed = [];
for (const token of tokens) {
const colonIdx = token.indexOf(":");
let label = null;
let valueText = token;
if (colonIdx > 0) {
label = token.substring(0, colonIdx).trim();
valueText = token.substring(colonIdx + 1).trim();
}
const value = parseFloat(valueText);
parsed.push({ label, value });
}
return parsed;
}

/**
* Find the dataset index that matches the incoming sample.
*
* If the sample carries a label, prefer matching against an existing dataset
* with the same label so labeled series stay on the same line across frames
* (and across reordering). Without a label, fall back to positional index so
* legacy unlabeled CSV / list / tuple behaviour is unchanged.
*
* @param {object} chartObj
* @param {{label: (string|null), value: number}} sample
* @param {number} positionalIndex
* @returns {number}
*/
function resolveDatasetIndex(chartObj, sample, positionalIndex) {
if (sample.label) {
for (let i = 0; i < chartObj.data.datasets.length; i++) {
if (chartObj.data.datasets[i].label === sample.label) {
return i;
}
}
}
return positionalIndex;
}

export function plotValues(chartObj, serialMessage, bufferSize) {
/*
Given a string serialMessage, parse it into the plottable value(s) that
Expand All @@ -44,7 +134,7 @@ export function plotValues(chartObj, serialMessage, bufferSize) {
continue;
}

let valuesToPlot;
let samples;

// handle possible tuple in textLine
if (textLine.startsWith("(") && textLine.endsWith(")")) {
Expand All @@ -54,24 +144,25 @@ export function plotValues(chartObj, serialMessage, bufferSize) {
textValues = textValues.substring(0, textValues.length - 1);
}
textLine = "[" + textValues + "]";
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])
let valuesToPlot;
try {
valuesToPlot = JSON.parse(textLine);
} catch (e) {
// Not a valid JSON list; skip this line.
continue;
}
samples = valuesToPlot.map(v => ({ label: null, value: parseFloat(v) }));
} else {
// Handle CSV / tab-separated / labeled values, matching the
// Arduino IDE Serial Plotter format. See parseLabeledValues.
samples = parseLabeledValues(textLine);
}

if (valuesToPlot === undefined || valuesToPlot.length === 0) {
if (samples === undefined || samples.length === 0) {
continue;
}

Expand All @@ -86,23 +177,38 @@ export function plotValues(chartObj, serialMessage, bufferSize) {
}
chartObj.data.labels.push("");

for (let i = 0; i < valuesToPlot.length; i++) {
if (isNaN(valuesToPlot[i])) {
for (let i = 0; i < samples.length; i++) {
const sample = samples[i];
if (isNaN(sample.value)) {
continue;
}
if (i > chartObj.data.datasets.length - 1) {
let curColor = '#000000';
if (i < defaultColors.length) {
curColor = defaultColors[i];
}
const datasetIndex = resolveDatasetIndex(chartObj, sample, i);
if (datasetIndex > chartObj.data.datasets.length - 1) {
const colorIdx = chartObj.data.datasets.length % defaultColors.length;
const curColor = defaultColors[colorIdx];
chartObj.data.datasets.push({
label: i.toString(),
label: sample.label !== null ? sample.label : datasetIndex.toString(),
data: [],
borderColor: curColor,
backgroundColor: curColor
});
} else if (sample.label && chartObj.data.datasets[datasetIndex].label !== sample.label) {
// Upgrade a previously-unlabeled positional dataset to use
// the label the sketch is now sending. This lets a sketch
// that starts unlabeled and switches to labels stay on the
// same series rather than spawning duplicates.
chartObj.data.datasets[datasetIndex].label = sample.label;
}
chartObj.data.datasets[datasetIndex].data.push(sample.value);
}

// Pad any datasets that didn't receive a sample on this frame so
// x-axis alignment stays consistent across labeled series.
for (let i = 0; i < chartObj.data.datasets.length; i++) {
const ds = chartObj.data.datasets[i];
while (ds.data.length < chartObj.data.labels.length) {
ds.data.push(null);
}
chartObj.data.datasets[i].data.push(valuesToPlot[i]);
}

updatePlotterScales(chartObj);
Expand All @@ -121,7 +227,12 @@ function updatePlotterScales(chartObj) {
*/
let allData = []
for (let i = 0; i < chartObj.data.datasets.length; i++) {
allData = allData.concat(chartObj.data.datasets[i].data)
// Filter out nulls used for x-axis padding so they don't break min/max.
const cleaned = chartObj.data.datasets[i].data.filter(v => v !== null && !isNaN(v));
allData = allData.concat(cleaned);
}
if (allData.length === 0) {
return;
}
chartObj.options.scales.y.min = Math.min(...allData) - 10
chartObj.options.scales.y.max = Math.max(...allData) + 10
Expand All @@ -142,6 +253,20 @@ export async function setupPlotterChart(workflow) {
type: 'line',
options: {
animation: false,
plugins: {
legend: {
// Show the legend so labeled series are easy to
// identify, matching the Arduino IDE Serial Plotter.
display: true,
position: 'top',
labels: {
// Pick a color that contrasts with the current
// theme's plotter background (set via
// --terminal-text-color in sass/layout/_themes.scss).
color: getCssVar('--terminal-text-color', '#ddd')
}
}
},
scales: {
y: {
min: -1,
Expand Down