[Perl] DAQ system for the FAPG.
feat add /insights page with overlay visualisations
Add a dedicated Insights page for secondary visualisations derived from primary probe readings: - DO & ORP dual-axis time-series overlay (shared time axis) - All probes normalised to optimal range (0-100%) overlay Remove DO vs ORP scatter plot from the front page and Probes page; experimental visualisations now live exclusively on /insights. Wire insights charts into graph controls and periodic refresh. Add navigation link and front page quick link.
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/public/js/dashboard/insights.js
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/dashboard/insights.html.ep
- roles/dashboard/templates/dashboard/probes.html.ep
- roles/dashboard/templates/layouts/default.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
@@ -74,6 +74,7 @@
74
74
$r->get('/')->to('dashboard#index');
75
75
$r->get('/status')->to('dashboard#status');
76
76
$r->get('/probes')->to('dashboard#probe_list');
77
Added:
$r->get('/insights')->to('dashboard#insights');
77
78
$r->get('/downloads')->to('dashboard#downloads');
78
79
$r->get('/downloads/readings.csv')->to('Download#readings');
79
80
$r->get('/downloads/readings.xlsx')->to('Download#spreadsheet');
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
@@ -69,6 +69,17 @@
69
69
);
70
70
}
71
71
72
Added:
sub insights ($self) {
73
Added:
$self->render(
74
Added:
template => 'dashboard/insights',
75
Added:
probes => $self->probes,
76
Added:
nav_page => 'insights',
77
Added:
breadcrumbs =>
78
Added:
[ { label => 'Home', href => '/' }, { label => 'Insights' }, ],
79
Added:
graph_control_aria_label => 'Insights chart timeframe',
80
Added:
);
81
Added:
}
82
Added:
72
83
sub graph ($self) {
73
84
my $probe_key = $self->stash('probe');
74
85
my ($probe) = grep { $_->{key} eq $probe_key } $self->probes->@*;
roles/dashboard/public/js/dashboard.js
@@ -1,6 +1,6 @@
1
1
import { initialiseGraphControls } from "./dashboard/graph-controls.js";
2
2
import { createProbeCharts } from "./dashboard/charts.js";
3
Removed:
import { createCorrelationChart } from "./dashboard/correlation.js";
3
Added:
import { createInsightsCharts } from "./dashboard/insights.js";
4
4
import { DASHBOARD_REFRESH_MS } from "./dashboard/constants.js";
5
5
import { loadQuickReadings } from "./dashboard/quick-readings.js";
6
6
import { createStatusLoader } from "./dashboard/status.js";
@@ -8,12 +8,7 @@
8
8
9
9
const timeframe = createTimeframeState();
10
10
const probes = createProbeCharts(timeframe);
11
Removed:
const correlation = createCorrelationChart(timeframe, "do", "orp", {
12
Removed:
labelX: "Dissolved Oxygen",
13
Removed:
labelY: "ORP",
14
Removed:
unitX: "mg/L",
15
Removed:
unitY: "mV"
16
Removed:
});
11
Added:
const insights = createInsightsCharts(timeframe);
17
12
const loadStatuses = createStatusLoader();
18
13
19
14
document.querySelectorAll(".tab-button").forEach(button => {
@@ -22,7 +17,7 @@
22
17
23
18
initialiseGraphControls(timeframe, () => {
24
19
probes.reloadDisplayed();
25
Removed:
correlation.reload();
20
Added:
insights.reload();
26
21
});
27
22
28
23
window.addEventListener("hashchange", () => {
@@ -50,7 +45,7 @@
50
45
51
46
refreshInFlight = Promise.allSettled([
52
47
probes.loadDisplayed(),
53
Removed:
correlation.reload(),
48
Added:
insights.reload(),
54
49
loadQuickReadings(),
55
50
loadStatuses()
56
51
]).finally(() => {
roles/dashboard/public/js/dashboard/insights.js
@@ -0,0 +1,276 @@
1
Added:
import { fetchProbeData } from "./api.js";
2
Added:
import { READING_RANGES, TIMEFRAMES } from "./constants.js";
3
Added:
import { formatChartTime, formatDateTime } from "./format.js";
4
Added:
5
Added:
const PROBE_COLORS = {
6
Added:
ph: { border: "#8b5cf6", background: "rgba(139, 92, 246, 0.12)" },
7
Added:
do: { border: "#0ea5e9", background: "rgba(14, 165, 233, 0.12)" },
8
Added:
orp: { border: "#f59e0b", background: "rgba(245, 158, 11, 0.12)" },
9
Added:
ec: { border: "#10b981", background: "rgba(16, 185, 129, 0.12)" }
10
Added:
};
11
Added:
12
Added:
const PROBE_LABELS = {
13
Added:
ph: "pH",
14
Added:
do: "DO (mg/L)",
15
Added:
orp: "ORP (mV)",
16
Added:
ec: "EC (µS/cm)"
17
Added:
};
18
Added:
19
Added:
let overlayChart = null;
20
Added:
let normalisedChart = null;
21
Added:
let activeRequests = [];
22
Added:
23
Added:
function extractSeries(points, timeframe) {
24
Added:
const labels = [];
25
Added:
const timestamps = [];
26
Added:
const values = [];
27
Added:
28
Added:
points.forEach(row => {
29
Added:
labels.push(formatChartTime(row.timestamp, timeframe));
30
Added:
timestamps.push(row.timestamp);
31
Added:
values.push(row.value === null ? null : Number(row.value));
32
Added:
});
33
Added:
34
Added:
return { labels, timestamps, values };
35
Added:
}
36
Added:
37
Added:
function normalise(value, probe) {
38
Added:
const range = READING_RANGES[probe];
39
Added:
if (!range || value === null || !Number.isFinite(value)) return null;
40
Added:
41
Added:
// Normalise to 0-100 based on goodMin–goodMax range
42
Added:
const span = range.goodMax - range.goodMin;
43
Added:
if (span === 0) return 50;
44
Added:
45
Added:
return ((value - range.goodMin) / span) * 100;
46
Added:
}
47
Added:
48
Added:
function registerTopLeftTooltipPositioner() {
49
Added:
if (Chart.Tooltip.positioners.dashboardTopLeft) return;
50
Added:
51
Added:
Chart.Tooltip.positioners.dashboardTopLeft = function () {
52
Added:
return {
53
Added:
x: this.chart.chartArea.left,
54
Added:
y: this.chart.chartArea.top
55
Added:
};
56
Added:
};
57
Added:
}
58
Added:
59
Added:
function createDualAxisChart(canvas, doSeries, orpSeries, timeframe) {
60
Added:
registerTopLeftTooltipPositioner();
61
Added:
62
Added:
return new Chart(canvas, {
63
Added:
type: "line",
64
Added:
data: {
65
Added:
labels: doSeries.labels,
66
Added:
timestamps: doSeries.timestamps,
67
Added:
datasets: [
68
Added:
{
69
Added:
label: "DO (mg/L)",
70
Added:
data: doSeries.values,
71
Added:
borderColor: PROBE_COLORS.do.border,
72
Added:
backgroundColor: PROBE_COLORS.do.background,
73
Added:
tension: 0.2,
74
Added:
pointRadius: 0,
75
Added:
pointHoverRadius: 4,
76
Added:
yAxisID: "yDO",
77
Added:
fill: true
78
Added:
},
79
Added:
{
80
Added:
label: "ORP (mV)",
81
Added:
data: orpSeries.values,
82
Added:
borderColor: PROBE_COLORS.orp.border,
83
Added:
backgroundColor: PROBE_COLORS.orp.background,
84
Added:
tension: 0.2,
85
Added:
pointRadius: 0,
86
Added:
pointHoverRadius: 4,
87
Added:
yAxisID: "yORP",
88
Added:
fill: true
89
Added:
}
90
Added:
]
91
Added:
},
92
Added:
options: {
93
Added:
responsive: true,
94
Added:
maintainAspectRatio: false,
95
Added:
animation: false,
96
Added:
interaction: { mode: "index", intersect: false },
97
Added:
scales: {
98
Added:
x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
99
Added:
yDO: {
100
Added:
type: "linear",
101
Added:
position: "left",
102
Added:
title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
103
Added:
ticks: { color: PROBE_COLORS.do.border },
104
Added:
grid: { drawOnChartArea: true }
105
Added:
},
106
Added:
yORP: {
107
Added:
type: "linear",
108
Added:
position: "right",
109
Added:
title: { display: true, text: "ORP (mV)", color: PROBE_COLORS.orp.border },
110
Added:
ticks: { color: PROBE_COLORS.orp.border },
111
Added:
grid: { drawOnChartArea: false }
112
Added:
}
113
Added:
},
114
Added:
plugins: {
115
Added:
legend: { display: true },
116
Added:
tooltip: {
117
Added:
mode: "index",
118
Added:
intersect: false,
119
Added:
position: "dashboardTopLeft",
120
Added:
xAlign: "left",
121
Added:
yAlign: "top",
122
Added:
caretSize: 0,
123
Added:
callbacks: {
124
Added:
title: items => {
125
Added:
const item = items[0];
126
Added:
const timestamp = item?.chart.data.timestamps?.[item.dataIndex];
127
Added:
return timestamp ? formatDateTime(timestamp) : "";
128
Added:
}
129
Added:
}
130
Added:
}
131
Added:
}
132
Added:
}
133
Added:
});
134
Added:
}
135
Added:
136
Added:
function createNormalisedChart(canvas, probeData, timeframe) {
137
Added:
registerTopLeftTooltipPositioner();
138
Added:
139
Added:
const probeKeys = Object.keys(probeData);
140
Added:
const referenceProbe = probeData[probeKeys[0]];
141
Added:
142
Added:
const datasets = probeKeys.map(probe => ({
143
Added:
label: PROBE_LABELS[probe] || probe,
144
Added:
data: probeData[probe].values.map(v => normalise(v, probe)),
145
Added:
borderColor: PROBE_COLORS[probe].border,
146
Added:
backgroundColor: PROBE_COLORS[probe].background,
147
Added:
tension: 0.2,
148
Added:
pointRadius: 0,
149
Added:
pointHoverRadius: 4,
150
Added:
fill: false
151
Added:
}));
152
Added:
153
Added:
return new Chart(canvas, {
154
Added:
type: "line",
155
Added:
data: {
156
Added:
labels: referenceProbe.labels,
157
Added:
timestamps: referenceProbe.timestamps,
158
Added:
datasets
159
Added:
},
160
Added:
options: {
161
Added:
responsive: true,
162
Added:
maintainAspectRatio: false,
163
Added:
animation: false,
164
Added:
interaction: { mode: "index", intersect: false },
165
Added:
scales: {
166
Added:
x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
167
Added:
y: {
168
Added:
title: { display: true, text: "% of optimal range" },
169
Added:
min: -50,
170
Added:
max: 150,
171
Added:
ticks: {
172
Added:
callback: value => `${value}%`
173
Added:
}
174
Added:
}
175
Added:
},
176
Added:
plugins: {
177
Added:
legend: { display: true },
178
Added:
tooltip: {
179
Added:
mode: "index",
180
Added:
intersect: false,
181
Added:
position: "dashboardTopLeft",
182
Added:
xAlign: "left",
183
Added:
yAlign: "top",
184
Added:
caretSize: 0,
185
Added:
callbacks: {
186
Added:
title: items => {
187
Added:
const item = items[0];
188
Added:
const timestamp = item?.chart.data.timestamps?.[item.dataIndex];
189
Added:
return timestamp ? formatDateTime(timestamp) : "";
190
Added:
},
191
Added:
label: context => {
192
Added:
const value = context.parsed.y;
193
Added:
if (value === null) return "";
194
Added:
return `${context.dataset.label}: ${value.toFixed(0)}%`;
195
Added:
}
196
Added:
}
197
Added:
},
198
Added:
annotation: undefined
199
Added:
}
200
Added:
}
201
Added:
});
202
Added:
}
203
Added:
204
Added:
export function createInsightsCharts(timeframe) {
205
Added:
const canvasOverlay = document.getElementById("chart-overlay-do-orp");
206
Added:
const canvasNormalised = document.getElementById("chart-overlay-all");
207
Added:
208
Added:
if (!canvasOverlay && !canvasNormalised) return { reload: () => {} };
209
Added:
210
Added:
async function load() {
211
Added:
activeRequests.forEach(c => c.abort());
212
Added:
activeRequests = [];
213
Added:
214
Added:
const controller = new AbortController();
215
Added:
activeRequests.push(controller);
216
Added:
217
Added:
const tf = timeframe.selected();
218
Added:
const range = timeframe.range();
219
Added:
220
Added:
try {
221
Added:
const [doData, orpData, phData, ecData] = await Promise.all([
222
Added:
fetchProbeData("do", tf, range, false, controller.signal),
223
Added:
fetchProbeData("orp", tf, range, false, controller.signal),
224
Added:
fetchProbeData("ph", tf, range, false, controller.signal),
225
Added:
fetchProbeData("ec", tf, range, false, controller.signal)
226
Added:
]);
227
Added:
228
Added:
const doSeries = extractSeries(doData.points, tf);
229
Added:
const orpSeries = extractSeries(orpData.points, tf);
230
Added:
const phSeries = extractSeries(phData.points, tf);
231
Added:
const ecSeries = extractSeries(ecData.points, tf);
232
Added:
233
Added:
// DO vs ORP dual-axis overlay
234
Added:
if (canvasOverlay) {
235
Added:
if (!overlayChart) {
236
Added:
overlayChart = createDualAxisChart(canvasOverlay, doSeries, orpSeries, tf);
237
Added:
} else {
238
Added:
overlayChart.data.labels = doSeries.labels;
239
Added:
overlayChart.data.timestamps = doSeries.timestamps;
240
Added:
overlayChart.data.datasets[0].data = doSeries.values;
241
Added:
overlayChart.data.datasets[1].data = orpSeries.values;
242
Added:
overlayChart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[tf].ticks;
243
Added:
overlayChart.update();
244
Added:
}
245
Added:
}
246
Added:
247
Added:
// Normalised all-probes overlay
248
Added:
if (canvasNormalised) {
249
Added:
const allProbeData = { ph: phSeries, do: doSeries, orp: orpSeries, ec: ecSeries };
250
Added:
251
Added:
if (!normalisedChart) {
252
Added:
normalisedChart = createNormalisedChart(canvasNormalised, allProbeData, tf);
253
Added:
} else {
254
Added:
const probeKeys = Object.keys(allProbeData);
255
Added:
normalisedChart.data.labels = allProbeData[probeKeys[0]].labels;
256
Added:
normalisedChart.data.timestamps = allProbeData[probeKeys[0]].timestamps;
257
Added:
probeKeys.forEach((probe, index) => {
258
Added:
normalisedChart.data.datasets[index].data =
259
Added:
allProbeData[probe].values.map(v => normalise(v, probe));
260
Added:
});
261
Added:
normalisedChart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[tf].ticks;
262
Added:
normalisedChart.update();
263
Added:
}
264
Added:
}
265
Added:
} catch (error) {
266
Added:
if (error.name === "AbortError") return;
267
Added:
console.error("Insights chart load failed:", error);
268
Added:
} finally {
269
Added:
activeRequests = activeRequests.filter(c => c !== controller);
270
Added:
}
271
Added:
}
272
Added:
273
Added:
load();
274
Added:
275
Added:
return { reload: load };
276
Added:
}
roles/dashboard/templates/dashboard/index.html.ep
@@ -11,6 +11,7 @@
11
11
<nav aria-label="Quick links">
12
12
<ul class="nav nav-pills">
13
13
<li class="nav-item"><a class="nav-link" href="/probes">Probes</a></li>
14
Added:
<li class="nav-item"><a class="nav-link" href="/insights">Insights</a></li>
14
15
<li class="nav-item"><a class="nav-link" href="/status">Status</a></li>
15
16
<li class="nav-item"><a class="nav-link" href="/downloads">Downloads</a></li>
16
17
</ul>
@@ -50,10 +51,3 @@
50
51
</section>
51
52
</section>
52
53
53
Removed:
<section class="mb-4" aria-label="Correlation charts">
54
Removed:
<div class="row g-3">
55
Removed:
<div class="col-12 col-md-6">
56
Removed:
%= include 'dashboard/correlation-card'
57
Removed:
</div>
58
Removed:
</div>
59
Removed:
</section>
roles/dashboard/templates/dashboard/insights.html.ep
@@ -0,0 +1,42 @@
1
Added:
<!-- -*- mode: web; -*- -->
2
Added:
3
Added:
% layout 'default';
4
Added:
% title 'FAPG DAQ Insights';
5
Added:
% stash use_charts => 1;
6
Added:
7
Added:
<section class="mb-4" aria-label="Experimental visualisations">
8
Added:
<div class="mb-3">
9
Added:
<h1 class="h4 fw-bold mb-1">Insights</h1>
10
Added:
<p class="text-body-secondary small mb-0">Secondary visualisations derived from primary probe readings</p>
11
Added:
</div>
12
Added:
13
Added:
<div class="row g-3">
14
Added:
<div class="col-12">
15
Added:
<div class="card">
16
Added:
<div class="card-header">
17
Added:
<h2 class="h5 mb-0">DO & ORP over time</h2>
18
Added:
<p class="text-body-secondary small mb-0 mt-1">Dissolved Oxygen and ORP on a shared time axis</p>
19
Added:
</div>
20
Added:
<div class="card-body">
21
Added:
<div class="chart-wrap">
22
Added:
<canvas id="chart-overlay-do-orp" role="img" aria-label="Dissolved Oxygen and ORP overlay chart"></canvas>
23
Added:
</div>
24
Added:
</div>
25
Added:
</div>
26
Added:
</div>
27
Added:
28
Added:
<div class="col-12">
29
Added:
<div class="card">
30
Added:
<div class="card-header">
31
Added:
<h2 class="h5 mb-0">All probes overlay</h2>
32
Added:
<p class="text-body-secondary small mb-0 mt-1">All four parameters normalised to their optimal range (0–100%)</p>
33
Added:
</div>
34
Added:
<div class="card-body">
35
Added:
<div class="chart-wrap">
36
Added:
<canvas id="chart-overlay-all" role="img" aria-label="All probes normalised overlay chart"></canvas>
37
Added:
</div>
38
Added:
</div>
39
Added:
</div>
40
Added:
</div>
41
Added:
</div>
42
Added:
</section>
roles/dashboard/templates/dashboard/probes.html.ep
@@ -10,7 +10,4 @@
10
10
%= include 'dashboard/probe-card', probe => $probe
11
11
</div>
12
12
% }
13
Removed:
<div class="col-12 col-md-6 col-xl-3">
14
Removed:
%= include 'dashboard/correlation-card'
15
Removed:
</div>
16
13
</div>
roles/dashboard/templates/layouts/default.html.ep
@@ -63,6 +63,13 @@
63
63
% }
64
64
</li>
65
65
<li class="nav-item">
66
Added:
% if ((stash('nav_page') // '') eq 'insights') {
67
Added:
<a class="nav-link fw-semibold active" href="/insights" aria-current="page">Insights</a>
68
Added:
% } else {
69
Added:
<a class="nav-link fw-semibold" href="/insights">Insights</a>
70
Added:
% }
71
Added:
</li>
72
Added:
<li class="nav-item">
66
73
% if ((stash('nav_page') // '') eq 'downloads') {
67
74
<a class="nav-link fw-semibold active" href="/downloads" aria-current="page">Downloads</a>
68
75
% } else {