aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authors-ol <s+removethis@s-ol.nu>2024-03-20 18:11:42 +0000
committers-ol <s+removethis@s-ol.nu>2024-03-26 18:23:29 +0000
commit2f691d7483b500d76a0955a6311e76a226751efd (patch)
treead607ab90a4e2fa6c6fd3e61cefaabb5440f501d
parentweb, simulator: support WebSocket transport for development (diff)
downloadt937-serial-2f691d7483b500d76a0955a6311e76a226751efd.tar.gz
t937-serial-2f691d7483b500d76a0955a6311e76a226751efd.zip
web: selectable, hideable plots
-rw-r--r--web/index.html41
-rw-r--r--web/main.js72
-rw-r--r--web/plot.js118
-rw-r--r--web/style.css20
4 files changed, 186 insertions, 65 deletions
diff --git a/web/index.html b/web/index.html
index 73717a7..7e37ab7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,6 +5,17 @@
<link rel="stylesheet" href="style.css" />
</head>
<body>
+ <svg style="display: none">
+ <defs>
+ <g id="i-eye" fill="currentColor">
+ <path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
+ <path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" />
+ </g>
+ <path id="i-no-eye" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
+ <path id="i-pencil" fill="currentColor" d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
+ <path id="i-no-pencil" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
+ </defs>
+ </svg>
<aside>
<section>
<h1>device</h1>
@@ -53,11 +64,33 @@
</div>
</section>
<section class="grow">
- <h1>profiles</h1>
+ <h1>
+ <span class="grow">reflow profile</span>
+ <span>
+ <button id="profile_show" class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><use href="#i-eye" /></svg></button>
+ <button id="profile_edit" class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><use href="#i-pencil" /></svg></button>
+ </span>
+ </h1>
<div>
- <button id="load" class="grow">load</button>
- <button id="save" class="grow">save</button>
- <button id="create" class="grow">create</button>
+ <button id="profile_load" class="grow">load</button>
+ <button id="profile_save" class="grow">save</button>
+ <button id="profile_create" class="grow">create</button>
+ </div>
+ <select id="storedprofile" multiple class="grow">
+ </select>
+ </section>
+ <section class="grow">
+ <h1>
+ <span class="grow">paste specification</span>
+ <span>
+ <button id="paste_show" class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><use href="#i-eye" /></svg></button>
+ <button id="paste_edit" class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><use href="#i-pencil" /></svg></button>
+ </span>
+ </h1>
+ <div>
+ <button id="paste_load" class="grow">load</button>
+ <button id="paste_save" class="grow">save</button>
+ <button id="paste_create" class="grow">create</button>
</div>
<select id="storedprofile" multiple class="grow">
</select>
diff --git a/web/main.js b/web/main.js
index 37ef19e..b93b2da 100644
--- a/web/main.js
+++ b/web/main.js
@@ -32,18 +32,27 @@ let START_TIME = null;
let END_TIME = null;
let TIMEOUT;
-const PLOTS = {
- paste: plot.addPlot('paste', 'slategray', [[0, 24]], true),
- setpoint: plot.addPlot('setpoint', 'teal', [[0, 24]], true),
- temp1: plot.addPlot('temp1', 'plum'),
- temp2: plot.addPlot('temp2', 'salmon'),
- temp3: plot.addPlot('temp3', 'sienna'),
- tempavg: plot.addPlot('tempavg', 'rebeccapurple'),
-};
+plot.addPlot('paste', {
+ color: 'slategray',
+ data: [[0, 24]],
+ interactive: true,
+});
+plot.addPlot('profile', {
+ color: 'teal',
+ data: [[0, 24]],
+ interactive: true,
+});
+plot.addPlot('temp1', { color: 'plum' });
+plot.addPlot('temp2', { color: 'salmon' });
+plot.addPlot('temp3', { color: 'sienna' });
+plot.addPlot('tempavg', { color: 'rebeccapurple' });
const elements = [
'temps_chamber', 'temps_controller',
- 'connect', 'status', 'profile', 'start', 'read', 'write',
+ 'connect', 'status',
+ 'profile', 'start', 'read', 'write',
+ 'profile_load', 'profile_save', 'profile_create', 'profile_show', 'profile_edit',
+ 'paste_load', 'paste_save', 'paste_create', 'paste_show', 'paste_edit',
'heat', 'heat_mode', 'cool', 'cool_mode',
];
const el = Object.fromEntries(elements.map(id => [id, document.getElementById(id)]));
@@ -57,6 +66,11 @@ const updateUI = () => {
el.read.disabled = !ready;
el.write.disabled = !ready;
+ el.profile_show.firstElementChild.firstElementChild.href.baseVal = plot.PLOTS.profile.visible ? '#i-eye' : '#i-no-eye';
+ el.paste_show.firstElementChild.firstElementChild.href.baseVal = plot.PLOTS.paste.visible ? '#i-eye' : '#i-no-eye';
+ el.profile_edit.firstElementChild.firstElementChild.href.baseVal = plot.ACTIVE === 'profile' ? '#i-pencil' : '#i-no-pencil';
+ el.paste_edit.firstElementChild.firstElementChild.href.baseVal = plot.ACTIVE === 'paste' ? '#i-pencil' : '#i-no-pencil';
+
const end = END_TIME ?? Date.now();
const duration = START_TIME && Math.floor((end - START_TIME) / 1000);
@@ -83,6 +97,8 @@ const updateUI = () => {
el.status.innerText = `profile ${+el.profile.value + 1} - ${duration}s`;
break;
}
+
+ plot.update();
};
updateUI();
@@ -133,12 +149,11 @@ const onMessage = async (msg) => {
if (!STATUS || STATUS === 'connecting') {
STATUS = 'idle';
} else if (STATUS === 'running' || STATUS == 'manual') {
- const t = PLOTS.temp1.data.length;
- PLOTS.temp1.data.push([t, temp1]);
- PLOTS.temp2.data.push([t, temp2]);
- PLOTS.temp3.data.push([t, temp3]);
- PLOTS.tempavg.data.push([t, (temp1 + temp2) / 2]);
- plot.update();
+ const t = plot.PLOTS.temp1.data.length;
+ plot.PLOTS.temp1.data.push([t, temp1]);
+ plot.PLOTS.temp2.data.push([t, temp2]);
+ plot.PLOTS.temp3.data.push([t, temp3]);
+ plot.PLOTS.tempavg.data.push([t, (temp1 + temp2) / 2]);
}
break;
}
@@ -194,11 +209,10 @@ el.start.onclick = () => {
START_TIME = Date.now();
END_TIME = null;
- PLOTS.temp1.data.length = 0;
- PLOTS.temp2.data.length = 0;
- PLOTS.temp3.data.length = 0;
- PLOTS.tempavg.data.length = 0;
- plot.update();
+ plot.PLOTS.temp1.data.length = 0;
+ plot.PLOTS.temp2.data.length = 0;
+ plot.PLOTS.temp3.data.length = 0;
+ plot.PLOTS.tempavg.data.length = 0;
if (el.profile.value !== '') {
const i = +el.profile.value;
@@ -214,6 +228,7 @@ el.start.onclick = () => {
STATUS = 'idle';
END_TIME = Date.now();
}
+
updateUI();
};
@@ -297,3 +312,20 @@ if (window.location.hash !== '#ws') {
console.log('opened websocket');
};
}
+el.profile_show.onclick = () => {
+ plot.PLOTS.profile.visible = !plot.PLOTS.profile.visible;
+ updateUI();
+};
+el.paste_show.onclick = () => {
+ plot.PLOTS.pase.visible = !plot.PLOTS.pase.visible;
+ updateUI();
+};
+el.profile_edit.onclick = () => {
+ plot.toggleActive('profile');
+ updateUI();
+};
+el.paste_edit.onclick = () => {
+ plot.toggleActive('paste');
+ updateUI();
+};
+};
diff --git a/web/plot.js b/web/plot.js
index 9095ec3..98d8d88 100644
--- a/web/plot.js
+++ b/web/plot.js
@@ -1,12 +1,11 @@
import './lib/d3.v7.min.js';
-let POINTS = [];
-
const x = d3.scaleLinear([0, 120]).nice(30);
-const y = d3.scaleLinear([0, 350]);
+const y = d3.scaleLinear([0, 350]).nice(25);
const line = d3.line(d => x(d[0]), d => y(d[1]));
-const margin = 2 * parseFloat(getComputedStyle(document.documentElement).fontSize);
+const marginx = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize);
+const marginy = 2 * parseFloat(getComputedStyle(document.documentElement).fontSize);
const svg = d3.select('#plot');
@@ -16,32 +15,59 @@ const xa = svg.append('g');
// Add the y-axis.
const ya = svg.append('g');
-const PLOTS = {};
+export const PLOTS = {};
+export let ACTIVE = null;
const sec = d3.format('02');
const min = d3.format(' ');
const minsec = v => `${min(Math.floor(v / 60))}:${sec(Math.floor(v % 60))}`;
const degc = v => `${Math.floor(v)}°C`;
-export const addPlot = (name, color, data=[], interactive=false) => {
- const path = svg.append('path')
- // .attr('cursor', 'copy')
+export const addPlot = (name, options={}) => {
+ const plot = Object.assign({
+ color: 'black',
+ data: [],
+ interactive: false,
+ visible: true,
+ }, options);
+
+ plot.root = svg.append('g')
+ .attr('fill', plot.color)
+ .attr('stroke', plot.color)
+ .on('click', (e) => {
+ if (!plot.interactive) return;
+ if (name === ACTIVE) return;
+
+ e.preventDefault();
+ toggleActive(name);
+ update();
+ });
+
+ plot.path = plot.root.append('path')
.attr('fill', 'none')
- .attr('stroke', color)
.attr('stroke-width', '2');
- const points = interactive && (
- svg.append('g')
- .attr('cursor', 'grab')
- );
-
- PLOTS[name] = {
- color,
- data,
- points,
- path,
- };
- return PLOTS[name];
+ if (plot.interactive) {
+ plot.shadow = plot.root.append('path')
+ .attr('fill', 'none')
+ .attr('stroke', 'transparent')
+ .attr('stroke-width', '8');
+
+ plot.points = plot.root.append('g')
+ .attr('stroke', 'none')
+ }
+
+ PLOTS[name] = plot;
+ return plot;
+};
+
+export const toggleActive = (name) => {
+ if (ACTIVE === name) {
+ ACTIVE = null;
+ } else if (PLOTS[name]) {
+ PLOTS[name].root.raise();
+ ACTIVE = name;
+ }
};
export const update = (transition=true) => {
@@ -55,15 +81,21 @@ export const update = (transition=true) => {
xa.transition().duration(dur).call(d3.axisBottom(x).tickFormat(minsec));
ya.transition().duration(dur).call(d3.axisLeft(y).tickFormat(degc));
- for (const { path, points, data, color } of Object.values(PLOTS)) {
+ for (const name of Object.keys(PLOTS)) {
+ const { root, path, shadow, points, data, color, interactive, visible } = PLOTS[name];
+ root
+ .attr('cursor', interactive && name !== ACTIVE ? 'pointer' : 'default')
+ .attr('display', visible ? null : 'none');
+
path.transition().duration(dur).attr('d', line(data));
+ shadow && shadow.transition().duration(dur).attr('d', line(data));
points && points.selectAll('circle')
.data(data)
.join('circle')
- .attr('fill', color)
- .attr('r', 5)
.attr('cx', (d) => x(d[0]))
.attr('cy', (d) => y(d[1]))
+ .attr('r', name === ACTIVE ? 5 : 3)
+ .attr('cursor', name === ACTIVE ? 'grab' : null)
.on('dblclick', function (e, d) {
e.stopPropagation();
if (d[0] === 0) return;
@@ -74,9 +106,12 @@ export const update = (transition=true) => {
})
.call(
d3.drag()
+ .filter((e) => !e.ctrlKey && !e.button && name === ACTIVE)
.on('start', () => points.attr('cursor', 'grabbing'))
.on('drag', function (e, d) {
- const [time, temp] = [x.invert(e.x), y.invert(e.y)];
+ let [time, temp] = [x.invert(e.x), y.invert(e.y)];
+ time = Math.max(1, time);
+ temp = Math.max(0, Math.min(temp, 400));
const ci = data.indexOf(d);
if (data[ci-1] && data[ci-1][0] > time) {
@@ -105,8 +140,8 @@ const onresize = () => {
.attr('width', width)
.attr('height', height);
- x.range([margin, width - margin]);
- y.range([height - margin, margin]);
+ x.range([marginx, width - marginx]);
+ y.range([height - marginy, marginy]);
xa.transition().call(d3.axisBottom(x).tickFormat(minsec));
ya.transition().call(d3.axisLeft(y).tickFormat(degc));
for (const { path, data, points } of Object.values(PLOTS)) {
@@ -116,8 +151,8 @@ const onresize = () => {
.attr('cy', (d) => y(d[1]));
}
- xa.attr('transform', `translate(0, ${height - margin})`);
- ya.attr('transform', `translate(${margin}, 0)`);
+ xa.attr('transform', `translate(0, ${height - marginy})`);
+ ya.attr('transform', `translate(${marginx}, 0)`);
};
window.addEventListener('resize', onresize);
@@ -129,13 +164,13 @@ const crosshair = svg.append('g')
.attr('stroke-width', 0.5);
crosshair.append('text')
.attr('id', 'ctextx')
- .attr('y', margin)
+ .attr('y', marginy)
.attr('dx', '0.32em')
.attr('dy', '1em')
.attr('fill', 'currentColor');
crosshair.append('text')
.attr('id', 'ctexty')
- .attr('x', margin)
+ .attr('x', marginx)
.attr('dx', '0.32em')
.attr('dy', '-0.32em')
.attr('fill', 'currentColor');;
@@ -153,26 +188,27 @@ svg
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
- const inRange = margin < px && px < width - margin &&
- margin < py && py < height - margin;
+ const inRange = marginx < px && px < width - marginx &&
+ marginy < py && py < height - marginy;
crosshair.attr('display', inRange ? null : 'none').lower();
crosshair.select('#ctextx').text(minsec(time)).attr('x', px);
crosshair.select('#ctexty').text(degc(temp)).attr('y', py);
- crosshair.select('#clinex').attr('x1', px).attr('x2', px).attr('y1', margin).attr('y2', height - margin);
- crosshair.select('#cliney').attr('y1', py).attr('y2', py).attr('x1', margin).attr('x2', width - margin);
+ crosshair.select('#clinex').attr('x1', px).attr('x2', px).attr('y1', marginy).attr('y2', height - marginy);
+ crosshair.select('#cliney').attr('y1', py).attr('y2', py).attr('x1', marginx).attr('x2', width - marginx);
})
.on('dblclick', (e) => {
+ const plot = ACTIVE && PLOTS[ACTIVE];
+ if (!plot) return;
+
const [px, py] = d3.pointer(e);
const [time, temp] = [x.invert(px), y.invert(py)];
- const current = PLOTS.setpoint;
-
- const ni = current.data.findLastIndex(([tt, tp]) => tt <= time);
- if (current.data[ni][0] === time) {
- current.data[ni][1] = temp;
+ const ni = plot.data.findLastIndex(([tt, tp]) => tt <= time);
+ if (plot.data[ni][0] === time) {
+ plot.data[ni][1] = temp;
} else {
- current.data.splice(ni + 1, 0, [time, temp]);
+ plot.data.splice(ni + 1, 0, [time, temp]);
}
update(false);
});
diff --git a/web/style.css b/web/style.css
index a397fce..8bbf5cf 100644
--- a/web/style.css
+++ b/web/style.css
@@ -21,6 +21,14 @@ aside {
overflow: hidden;
resize: horizontal;
}
+aside h1 {
+ display: flex;
+ gap: 1rem;
+ font-size: 1.5em;
+}
+aside h1 span {
+ display: flex;
+}
aside section {
display: flex;
flex-direction: column;
@@ -48,6 +56,18 @@ aside div label + label {
flex-grow: 1;
}
+button.icon {
+ border: none;
+ background: none;
+ appearance: none;
+ font: inherit;
+ min-width: 0;
+ cursor: pointer;
+}
+button.icon svg {
+ height: 0.5lh;
+}
+
#plot {
flex: 1 0 400px;
user-select: none;