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.

Commit
04e99b5599202173ed436e0adaacac1741e46c07
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index a71ee739..2f940bac 100644..100644
@@ -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
index 4e316cf5..99896810 100644..100644
@@ -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
index 35f66d64..354535eb 100644..100644
@@ -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
index 00000000..6ce80004 000000..100644
@@ -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
index c4f6fcfe..b6e8f922 100644..100644
@@ -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
index 00000000..5ec6bdc8 000000..100644
@@ -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 &amp; 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
index b2eedf75..059f04c7 100644..100644
@@ -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
index fac43a4c..579a1522 100644..100644
@@ -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 {