Add graph data smoothing control.

Commit
026a8e6f993c021a9e87802a015278ea9b827b10
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/public/css/app.css
index 1dc75065..5ecf16ff 100644..100644
@@ -241,6 +241,23 @@
241 241 color: white;
242 242 }
243 243
244 Added: .smoothing-control {
245 Added: display: inline-flex;
246 Added: align-items: center;
247 Added: gap: 0.45rem;
248 Added: min-height: 2.05rem;
249 Added: color: var(--text);
250 Added: cursor: pointer;
251 Added: font-size: 0.88rem;
252 Added: font-weight: 700;
253 Added: }
254 Added:
255 Added: .smoothing-control input {
256 Added: width: 1rem;
257 Added: height: 1rem;
258 Added: accent-color: var(--accent);
259 Added: }
260 Added:
244 261 .chart-wrap {
245 262 min-height: 420px;
246 263 }
@@ -330,6 +347,10 @@
330 347
331 348 .timeframe-button:nth-child(odd) {
332 349 border-left: 0;
350 Added: }
351 Added:
352 Added: .smoothing-control {
353 Added: width: 100%;
333 354 }
334 355
335 356 .section-heading {
roles/dashboard/public/js/dashboard.js
index 138bbbbc..caf5bf91 100644..100644
@@ -25,7 +25,9 @@
25 25 orp: 200,
26 26 ec: 500
27 27 };
28 Added: const SMOOTHING_WINDOW = 7;
28 29 let activeTimeframe = DEFAULT_TIMEFRAME;
30 Added: let smoothingEnabled = true;
29 31
30 32 function probeRows() {
31 33 return Array.from(document.querySelectorAll("[data-latest-probe]"));
@@ -47,6 +49,10 @@
47 49 return document.querySelector(`[data-reading-messages="${probe}"]`);
48 50 }
49 51
52 Added: function smoothingToggles() {
53 Added: return Array.from(document.querySelectorAll("[data-smoothing-toggle]"));
54 Added: }
55 Added:
50 56 function labelForRow(row) {
51 57 return formatChartTime(row.timestamp);
52 58 }
@@ -157,6 +163,34 @@
157 163 };
158 164 }
159 165
166 Added: function movingAverage(values, windowSize = SMOOTHING_WINDOW) {
167 Added: if (values.length < 3 || windowSize < 3) {
168 Added: return values;
169 Added: }
170 Added:
171 Added: const radius = Math.floor(windowSize / 2);
172 Added:
173 Added: return values.map((value, index) => {
174 Added: if (!Number.isFinite(value)) {
175 Added: return value;
176 Added: }
177 Added:
178 Added: const start = Math.max(0, index - radius);
179 Added: const end = Math.min(values.length, index + radius + 1);
180 Added: const neighbors = values.slice(start, end).filter(Number.isFinite);
181 Added:
182 Added: if (!neighbors.length) {
183 Added: return value;
184 Added: }
185 Added:
186 Added: return neighbors.reduce((sum, neighbor) => sum + neighbor, 0) / neighbors.length;
187 Added: });
188 Added: }
189 Added:
190 Added: function chartValues(values) {
191 Added: return smoothingEnabled ? movingAverage(values) : values;
192 Added: }
193 Added:
160 194 function statusLabel(state) {
161 195 return {
162 196 online: "Online",
@@ -313,7 +347,8 @@
313 347 const rows = payload.readings || [];
314 348
315 349 const labels = rows.map(labelForRow);
316 Removed: const values = rows.map(row => Number(row.value));
350 Added: const rawValues = rows.map(row => Number(row.value));
351 Added: const values = chartValues(rawValues);
317 352 const unit = rows.length ? rows[rows.length - 1].unit : "";
318 353 const yBounds = yAxisBounds(probe, values);
319 354
@@ -325,7 +360,7 @@
325 360 data: {
326 361 labels,
327 362 datasets: [{
328 Removed: label: `${probe} ${unit ? `(${unit})` : ""}`,
363 Added: label: `${probe} ${unit ? `(${unit})` : ""}${smoothingEnabled ? " smoothed" : ""}`,
329 364 data: values,
330 365 tension: 0.2,
331 366 pointRadius: 0
@@ -364,7 +399,7 @@
364 399
365 400 chart.data.labels = labels;
366 401 chart.data.datasets[0].data = values;
367 Removed: chart.data.datasets[0].label = `${probe} ${unit ? `(${unit})` : ""}`;
402 Added: chart.data.datasets[0].label = `${probe} ${unit ? `(${unit})` : ""}${smoothingEnabled ? " smoothed" : ""}`;
368 403 chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[selectedTimeframe()].ticks;
369 404 chart.options.scales.y.min = yBounds.min;
370 405 chart.options.scales.y.max = yBounds.max;
@@ -413,6 +448,16 @@
413 448 loadProbe(activeProbe());
414 449 }
415 450
451 Added: function setSmoothing(enabled) {
452 Added: smoothingEnabled = enabled;
453 Added:
454 Added: smoothingToggles().forEach(toggle => {
455 Added: toggle.checked = smoothingEnabled;
456 Added: });
457 Added:
458 Added: loadProbe(activeProbe());
459 Added: }
460 Added:
416 461 document.querySelectorAll(".tab-button").forEach(button => {
417 462 button.addEventListener("click", () => {
418 463 activateTab(button.dataset.probe);
@@ -422,6 +467,12 @@
422 467 document.querySelectorAll(".timeframe-button").forEach(button => {
423 468 button.addEventListener("click", () => {
424 469 setTimeframe(button.dataset.timeframe);
470 Added: });
471 Added: });
472 Added:
473 Added: smoothingToggles().forEach(toggle => {
474 Added: toggle.addEventListener("change", () => {
475 Added: setSmoothing(toggle.checked);
425 476 });
426 477 });
427 478
roles/dashboard/t/basic.t
index 2625ac6e..a1f71934 100644..100644
@@ -120,6 +120,8 @@
120 120 ->content_like(qr/Daily/)
121 121 ->content_like(qr/Weekly/)
122 122 ->content_like(qr/Yearly/)
123 Added: ->content_like(qr/data-smoothing-toggle checked/)
124 Added: ->content_like(qr/smoothing/)
123 125 ->content_like(qr/Last 10 pH Reading Messages/)
124 126 ->content_like(qr/Last 10 Dissolved Oxygen Reading Messages/)
125 127 ->content_like(qr/status-pill-list/)
roles/dashboard/templates/dashboard/index.html.ep
index 82663379..ff41b363 100644..100644
@@ -59,6 +59,10 @@
59 59 <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
60 60 <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
61 61 </div>
62 Added: <label class="smoothing-control">
63 Added: <input type="checkbox" data-smoothing-toggle checked>
64 Added: <span>smoothing</span>
65 Added: </label>
62 66 </div>
63 67 </div>
64 68