[Perl] DAQ system for the FAPG.
feat consolidate weather page into single chart
Replace three separate charts (temperature, humidity, rainfall) with one combined multi-axis chart: - Left axis: temperature (°C, red line) - Right axis 1: humidity (%, blue line) - Right axis 2: rainfall (mm, grey bars) Easier to correlate weather patterns at a glance.
Changed files
roles/dashboard/public/js/dashboard/weather-page.js
@@ -14,7 +14,7 @@
14
14
caretSize: 0
15
15
};
16
16
17
Removed:
let charts = {};
17
Added:
let chart = null;
18
18
19
19
async function fetchJson(url) {
20
20
const response = await fetch(url);
@@ -29,158 +29,117 @@
29
29
return Math.round((ms * range) / (60 * 60 * 1000));
30
30
}
31
31
32
Removed:
async function loadTemperature(timeframe, signal) {
33
Removed:
const canvas = document.getElementById("chart-weather-temperature");
32
Added:
async function loadCombined(timeframe) {
33
Added:
const canvas = document.getElementById("chart-weather-combined");
34
34
if (!canvas) return;
35
35
36
36
const hours = timeframeToHours(timeframe);
37
37
const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
38
38
const labels = data.points.map(p => formatDateTime(p.timestamp));
39
Removed:
const values = data.points.map(p => p.temperature);
39
Added:
const temps = data.points.map(p => p.temperature);
40
Added:
const humids = data.points.map(p => p.humidity);
41
Added:
const rains = data.points.map(p => p.rain || 0);
40
42
41
Removed:
if (!charts.temperature) {
42
Removed:
charts.temperature = new Chart(canvas, {
43
Added:
const datasets = [
44
Added:
{
43
45
type: "line",
44
Removed:
data: {
45
Removed:
labels,
46
Removed:
datasets: [{
47
Removed:
label: "Temperature (°C)",
48
Removed:
data: values,
49
Removed:
borderColor: "#dc2626",
50
Removed:
backgroundColor: "rgba(220, 38, 38, 0.08)",
51
Removed:
tension: 0.3,
52
Removed:
pointRadius: 0,
53
Removed:
pointHoverRadius: 3,
54
Removed:
fill: true
55
Removed:
}]
56
Removed:
},
57
Removed:
options: {
58
Removed:
responsive: true,
59
Removed:
maintainAspectRatio: false,
60
Removed:
animation: false,
61
Removed:
scales: {
62
Removed:
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
63
Removed:
y: { title: { display: true, text: "°C" } }
64
Removed:
},
65
Removed:
plugins: {
66
Removed:
legend: { display: false },
67
Removed:
tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
68
Removed:
}
69
Removed:
}
70
Removed:
});
71
Removed:
} else {
72
Removed:
charts.temperature.data.labels = labels;
73
Removed:
charts.temperature.data.datasets[0].data = values;
74
Removed:
charts.temperature.update();
75
Removed:
}
76
Removed:
}
77
Removed:
78
Removed:
async function loadHumidity(timeframe, signal) {
79
Removed:
const canvas = document.getElementById("chart-weather-humidity");
80
Removed:
if (!canvas) return;
81
Removed:
82
Removed:
const hours = timeframeToHours(timeframe);
83
Removed:
const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
84
Removed:
const labels = data.points.map(p => formatDateTime(p.timestamp));
85
Removed:
const values = data.points.map(p => p.humidity);
86
Removed:
87
Removed:
if (!charts.humidity) {
88
Removed:
charts.humidity = new Chart(canvas, {
46
Added:
label: "Temperature (°C)",
47
Added:
data: temps,
48
Added:
borderColor: "#dc2626",
49
Added:
backgroundColor: "rgba(220, 38, 38, 0.06)",
50
Added:
tension: 0.3,
51
Added:
pointRadius: 0,
52
Added:
pointHoverRadius: 3,
53
Added:
yAxisID: "yTemp",
54
Added:
fill: true,
55
Added:
order: 1
56
Added:
},
57
Added:
{
89
58
type: "line",
90
Removed:
data: {
91
Removed:
labels,
92
Removed:
datasets: [{
93
Removed:
label: "Humidity (%)",
94
Removed:
data: values,
95
Removed:
borderColor: "#0ea5e9",
96
Removed:
backgroundColor: "rgba(14, 165, 233, 0.08)",
97
Removed:
tension: 0.3,
98
Removed:
pointRadius: 0,
99
Removed:
pointHoverRadius: 3,
100
Removed:
fill: true
101
Removed:
}]
102
Removed:
},
103
Removed:
options: {
104
Removed:
responsive: true,
105
Removed:
maintainAspectRatio: false,
106
Removed:
animation: false,
107
Removed:
scales: {
108
Removed:
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
109
Removed:
y: { title: { display: true, text: "%" }, min: 0, max: 100 }
110
Removed:
},
111
Removed:
plugins: {
112
Removed:
legend: { display: false },
113
Removed:
tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
114
Removed:
}
115
Removed:
}
116
Removed:
});
117
Removed:
} else {
118
Removed:
charts.humidity.data.labels = labels;
119
Removed:
charts.humidity.data.datasets[0].data = values;
120
Removed:
charts.humidity.update();
121
Removed:
}
122
Removed:
}
59
Added:
label: "Humidity (%)",
60
Added:
data: humids,
61
Added:
borderColor: "#0ea5e9",
62
Added:
backgroundColor: "rgba(14, 165, 233, 0.06)",
63
Added:
tension: 0.3,
64
Added:
pointRadius: 0,
65
Added:
pointHoverRadius: 3,
66
Added:
yAxisID: "yHumid",
67
Added:
fill: true,
68
Added:
order: 2
69
Added:
},
70
Added:
{
71
Added:
type: "bar",
72
Added:
label: "Rain (mm)",
73
Added:
data: rains,
74
Added:
backgroundColor: "rgba(100, 116, 139, 0.4)",
75
Added:
borderColor: "#64748b",
76
Added:
borderWidth: 1,
77
Added:
yAxisID: "yRain",
78
Added:
order: 3
79
Added:
}
80
Added:
];
123
81
124
Removed:
async function loadRain(timeframe, signal) {
125
Removed:
const canvas = document.getElementById("chart-weather-rain");
126
Removed:
if (!canvas) return;
127
Removed:
128
Removed:
const hours = timeframeToHours(timeframe);
129
Removed:
const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
130
Removed:
const labels = data.points.map(p => formatDateTime(p.timestamp));
131
Removed:
const values = data.points.map(p => p.rain || 0);
132
Removed:
133
Removed:
if (!charts.rain) {
134
Removed:
charts.rain = new Chart(canvas, {
82
Added:
if (!chart) {
83
Added:
chart = new Chart(canvas, {
135
84
type: "bar",
136
Removed:
data: {
137
Removed:
labels,
138
Removed:
datasets: [{
139
Removed:
label: "Rain (mm)",
140
Removed:
data: values,
141
Removed:
backgroundColor: "rgba(14, 165, 233, 0.5)",
142
Removed:
borderColor: "#0ea5e9",
143
Removed:
borderWidth: 1
144
Removed:
}]
145
Removed:
},
85
Added:
data: { labels, datasets },
146
86
options: {
147
87
responsive: true,
148
88
maintainAspectRatio: false,
149
89
animation: false,
90
Added:
interaction: { mode: "index", intersect: false },
150
91
scales: {
151
Removed:
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
152
Removed:
y: { beginAtZero: true, title: { display: true, text: "mm" } }
92
Added:
x: { ticks: { maxTicksLimit: 10, font: { size: 10 } } },
93
Added:
yTemp: {
94
Added:
type: "linear",
95
Added:
position: "left",
96
Added:
title: { display: true, text: "°C", color: "#dc2626" },
97
Added:
ticks: { color: "#dc2626", font: { size: 10 } },
98
Added:
grid: { display: true }
99
Added:
},
100
Added:
yHumid: {
101
Added:
type: "linear",
102
Added:
position: "right",
103
Added:
min: 0,
104
Added:
max: 100,
105
Added:
title: { display: true, text: "%", color: "#0ea5e9" },
106
Added:
ticks: { color: "#0ea5e9", font: { size: 10 } },
107
Added:
grid: { drawOnChartArea: false }
108
Added:
},
109
Added:
yRain: {
110
Added:
type: "linear",
111
Added:
position: "right",
112
Added:
beginAtZero: true,
113
Added:
title: { display: true, text: "mm", color: "#64748b" },
114
Added:
ticks: { color: "#64748b", font: { size: 10 } },
115
Added:
grid: { drawOnChartArea: false },
116
Added:
// Offset to not overlap with humidity axis
117
Added:
afterFit(axis) { axis.paddingLeft = 10; }
118
Added:
}
153
119
},
154
120
plugins: {
155
Removed:
legend: { display: false },
121
Added:
legend: { display: true, labels: { font: { size: 10 } } },
156
122
tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
157
123
}
158
124
}
159
125
});
160
126
} else {
161
Removed:
charts.rain.data.labels = labels;
162
Removed:
charts.rain.data.datasets[0].data = values;
163
Removed:
charts.rain.update();
127
Added:
chart.data.labels = labels;
128
Added:
chart.data.datasets[0].data = temps;
129
Added:
chart.data.datasets[1].data = humids;
130
Added:
chart.data.datasets[2].data = rains;
131
Added:
chart.update();
164
132
}
165
133
}
166
134
167
135
export function createWeatherCharts() {
168
Removed:
const hasCanvas = document.getElementById("chart-weather-temperature");
136
Added:
const hasCanvas = document.getElementById("chart-weather-combined");
169
137
if (!hasCanvas) return { reload: () => {} };
170
138
171
139
const timeframe = createTimeframeState();
172
Removed:
let controller = null;
173
140
174
141
function loadAll() {
175
Removed:
if (controller) controller.abort();
176
Removed:
controller = new AbortController();
177
Removed:
const signal = controller.signal;
178
Removed:
179
Removed:
return Promise.allSettled([
180
Removed:
loadTemperature(timeframe, signal),
181
Removed:
loadHumidity(timeframe, signal),
182
Removed:
loadRain(timeframe, signal)
183
Removed:
]);
142
Added:
return loadCombined(timeframe);
184
143
}
185
144
186
145
initialiseGraphControls(timeframe, () => loadAll());
roles/dashboard/t/09-weather.t
@@ -97,9 +97,7 @@
97
97
$t->get_ok('/weather')
98
98
->status_is(200)
99
99
->text_is('title', 'FAPG DAQ Weather')
100
Removed:
->element_exists('canvas#chart-weather-temperature')
101
Removed:
->element_exists('canvas#chart-weather-humidity')
102
Removed:
->element_exists('canvas#chart-weather-rain')
100
Added:
->element_exists('canvas#chart-weather-combined')
103
101
->element_exists('[data-default-timeframe="day"]');
104
102
};
105
103
roles/dashboard/templates/dashboard/weather.html.ep
@@ -12,34 +12,12 @@
12
12
</div>
13
13
14
14
<div class="row g-3">
15
Removed:
<div class="col-12" id="weather-temperature">
15
Added:
<div class="col-12" id="weather-combined">
16
16
<div class="card">
17
Removed:
%= include 'dashboard/chart-card-header', title => 'Temperature', info_id => 'info-weather-temp', info_text => 'Outdoor air temperature at 2m above ground from the Météo France AROME model (1.5 km resolution). Relevant for estimating greenhouse and water temperature trends.'
17
Added:
%= include 'dashboard/chart-card-header', title => 'Temperature, Humidity & Rainfall', info_id => 'info-weather', info_text => 'Outdoor air temperature (°C) and relative humidity (%) from the Météo France AROME model (1.5 km resolution). Rainfall shown as bars (mm/h). Relevant for estimating greenhouse conditions and water temperature trends.'
18
18
<div class="card-body">
19
19
<div class="chart-wrap">
20
Removed:
<canvas id="chart-weather-temperature" role="img" aria-label="Outdoor temperature over time"></canvas>
21
Removed:
</div>
22
Removed:
</div>
23
Removed:
</div>
24
Removed:
</div>
25
Removed:
26
Removed:
<div class="col-12" id="weather-humidity">
27
Removed:
<div class="card">
28
Removed:
%= include 'dashboard/chart-card-header', title => 'Humidity', info_id => 'info-weather-humid', info_text => 'Outdoor relative humidity at 2m. High humidity reduces evaporation and plant transpiration; low humidity increases water loss from open systems.'
29
Removed:
<div class="card-body">
30
Removed:
<div class="chart-wrap">
31
Removed:
<canvas id="chart-weather-humidity" role="img" aria-label="Outdoor relative humidity over time"></canvas>
32
Removed:
</div>
33
Removed:
</div>
34
Removed:
</div>
35
Removed:
</div>
36
Removed:
37
Removed:
<div class="col-12" id="weather-rain">
38
Removed:
<div class="card">
39
Removed:
%= include 'dashboard/chart-card-header', title => 'Rainfall', info_id => 'info-weather-rain', info_text => 'Hourly rainfall in mm. Heavy rain can dilute outdoor systems, cause overflow, and affect nutrient balance. Indoor systems are less affected but ambient humidity rises.'
40
Removed:
<div class="card-body">
41
Removed:
<div class="chart-wrap">
42
Removed:
<canvas id="chart-weather-rain" role="img" aria-label="Rainfall over time"></canvas>
20
Added:
<canvas id="chart-weather-combined" role="img" aria-label="Temperature, humidity and rainfall over time"></canvas>
43
21
</div>
44
22
</div>
45
23
</div>