[Perl] DAQ system for the FAPG.
feat replace gauge color zones with red-yellow-green scale
Replace the binary good/bad color zones on the front page reading gauges with a continuous red → yellow → green gradient. - Add readingQuality() scoring: 0 (worst) to 1 (best), per-probe - DO uses higherIsBetter mode (higher values are increasingly green) - Symmetric probes (pH, EC, ORP) taper from green in the good range to yellow then red at extremes - Marker dot color interpolates dynamically to match its position - CSS gradient is now set per-probe by JS via --range-gradient - Remove hardcoded text-success on target range label
Changed files
roles/dashboard/public/css/app.css
@@ -175,10 +175,7 @@
175
175
Reading range bar (home page quick-reading cards)
176
176
---------------------------------------------------------------- */
177
177
.reading-quick-item {
178
Removed:
--warn-low: 27%;
179
Removed:
--good-start: 35%;
180
Removed:
--good-end: 65%;
181
Removed:
--warn-high: 73%;
178
Added:
--range-gradient: linear-gradient(90deg, #b42318 0%, #e8a308 35%, #276749 50%, #e8a308 65%, #b42318 100%);
182
179
}
183
180
184
181
/* Three-column label row under the range bar */
@@ -190,16 +187,7 @@
190
187
position: relative;
191
188
height: 0.85rem;
192
189
border-radius: 999px;
193
Removed:
background:
194
Removed:
linear-gradient(
195
Removed:
90deg,
196
Removed:
var(--fapg-danger) 0%,
197
Removed:
var(--fapg-warn) var(--warn-low),
198
Removed:
var(--fapg-accent) var(--good-start),
199
Removed:
var(--fapg-accent) var(--good-end),
200
Removed:
var(--fapg-warn) var(--warn-high),
201
Removed:
var(--fapg-danger) 100%
202
Removed:
);
190
Added:
background: var(--range-gradient);
203
191
}
204
192
205
193
.reading-range-marker {
@@ -214,10 +202,6 @@
214
202
box-shadow: 0 1px 4px rgba(23, 33, 27, 0.35);
215
203
transform: translate(-50%, -50%);
216
204
}
217
Removed:
218
Removed:
.reading-quick-item.is-good .reading-range-marker { background: var(--fapg-accent); }
219
Removed:
.reading-quick-item.is-low .reading-range-marker,
220
Removed:
.reading-quick-item.is-high .reading-range-marker { background: var(--fapg-danger); }
221
205
222
206
/* Status node detail-list term labels */
223
207
.status-node-dt {
roles/dashboard/public/js/dashboard/constants.js
@@ -11,7 +11,7 @@
11
11
export const MIN_Y_SPAN = { ph: 2, do: 5, orp: 200, ec: 500 };
12
12
export const READING_RANGES = {
13
13
ph: { min: 5.5, goodMin: 6.4, goodMax: 7.2, max: 8.5, precision: 2 },
14
Removed:
do: { min: 0, goodMin: 5.5, goodMax: 10, max: 14, precision: 2 },
14
Added:
do: { min: 0, goodMin: 5.5, goodMax: 10, max: 14, precision: 2, higherIsBetter: true },
15
15
orp: { min: 100, goodMin: 250, goodMax: 400, max: 500, precision: 0 },
16
16
ec: { min: 0, goodMin: 300, goodMax: 1500, max: 2500, precision: 0 }
17
17
};
roles/dashboard/public/js/dashboard/quick-readings.js
@@ -10,51 +10,97 @@
10
10
return Math.max(0, Math.min(100, ((value - range.min) / (range.max - range.min)) * 100));
11
11
}
12
12
13
Removed:
function readingState(value, range) {
14
Removed:
if (!Number.isFinite(value)) return "unknown";
15
Removed:
if (value < range.goodMin) return "low";
16
Removed:
if (value > range.goodMax) return "high";
17
Removed:
return "good";
13
Added:
function readingQuality(value, range) {
14
Added:
if (!Number.isFinite(value)) return 0;
15
Added:
16
Added:
if (range.higherIsBetter) {
17
Added:
// 0 at min, 1 at goodMax and above
18
Added:
if (value >= range.goodMax) return 1;
19
Added:
if (value <= range.min) return 0;
20
Added:
return (value - range.min) / (range.goodMax - range.min);
21
Added:
}
22
Added:
23
Added:
// Symmetric: 1 inside good range, tapering to 0 at extremes
24
Added:
if (value >= range.goodMin && value <= range.goodMax) return 1;
25
Added:
if (value < range.goodMin) {
26
Added:
return Math.max(0, (value - range.min) / (range.goodMin - range.min));
27
Added:
}
28
Added:
return Math.max(0, (range.max - value) / (range.max - range.goodMax));
18
29
}
19
30
20
Removed:
function readingStateLabel(state) {
21
Removed:
return { good: "Good", low: "Too low", high: "Too high", unknown: "No data" }[state] || "No data";
31
Added:
function qualityLabel(quality) {
32
Added:
if (quality >= 0.8) return "Good";
33
Added:
if (quality >= 0.5) return "Fair";
34
Added:
if (quality > 0) return "Poor";
35
Added:
return "No data";
22
36
}
23
37
38
Added:
function qualityState(quality) {
39
Added:
if (quality >= 0.8) return "good";
40
Added:
if (quality >= 0.5) return "fair";
41
Added:
if (quality > 0) return "poor";
42
Added:
return "unknown";
43
Added:
}
44
Added:
24
45
function configureRange(item, range, unit) {
25
46
const goodStart = rangePercent(range.goodMin, range);
26
47
const goodEnd = rangePercent(range.goodMax, range);
27
48
28
Removed:
item.style.setProperty("--warn-low", `${Math.max(0, goodStart - 8)}%`);
29
Removed:
item.style.setProperty("--good-start", `${goodStart}%`);
30
Removed:
item.style.setProperty("--good-end", `${goodEnd}%`);
31
Removed:
item.style.setProperty("--warn-high", `${Math.min(100, goodEnd + 8)}%`);
49
Added:
if (range.higherIsBetter) {
50
Added:
item.style.setProperty("--range-gradient",
51
Added:
`linear-gradient(90deg, #b42318 0%, #e8a308 ${goodStart}%, #276749 ${goodEnd}%, #276749 100%)`);
52
Added:
} else {
53
Added:
item.style.setProperty("--range-gradient",
54
Added:
`linear-gradient(90deg, #b42318 0%, #e8a308 ${Math.max(0, goodStart - 5)}%, #276749 ${goodStart}%, #276749 ${goodEnd}%, #e8a308 ${Math.min(100, goodEnd + 5)}%, #b42318 100%)`);
55
Added:
}
56
Added:
32
57
item.querySelector("[data-reading-quick-min]").textContent = `${formatValue(range.min, range)} ${unit}`;
33
58
item.querySelector("[data-reading-quick-target]").textContent =
34
59
`${formatValue(range.goodMin, range)}-${formatValue(range.goodMax, range)} ${unit}`;
35
60
item.querySelector("[data-reading-quick-max]").textContent = `${formatValue(range.max, range)} ${unit}`;
36
61
}
37
62
63
Added:
function markerColor(quality) {
64
Added:
// Interpolate red → yellow → green based on quality (0–1)
65
Added:
if (quality <= 0.5) {
66
Added:
const t = quality / 0.5;
67
Added:
const r = Math.round(180 + (232 - 180) * t);
68
Added:
const g = Math.round(35 + (163 - 35) * t);
69
Added:
const b = Math.round(24 + (8 - 24) * t);
70
Added:
return `rgb(${r}, ${g}, ${b})`;
71
Added:
}
72
Added:
const t = (quality - 0.5) / 0.5;
73
Added:
const r = Math.round(232 + (39 - 232) * t);
74
Added:
const g = Math.round(163 + (103 - 163) * t);
75
Added:
const b = Math.round(8 + (73 - 8) * t);
76
Added:
return `rgb(${r}, ${g}, ${b})`;
77
Added:
}
78
Added:
38
79
function updateItem(item, reading) {
39
80
const probe = item.dataset.readingQuickProbe;
40
81
const range = READING_RANGES[probe];
41
82
const unit = item.dataset.readingQuickUnit || reading?.unit || "";
42
83
const label = item.dataset.readingQuickLabel || probe;
43
84
const value = Number(reading?.value);
44
Removed:
const state = readingState(value, range);
85
Added:
const quality = readingQuality(value, range);
86
Added:
const state = qualityState(quality);
45
87
const percent = Number.isFinite(value) ? rangePercent(value, range) : 50;
46
88
const timestamp = reading?.received_at || reading?.timestamp;
47
89
const age = timestamp ? formatAge(ageMs(timestamp)) : "unknown age";
48
90
49
91
configureRange(item, range, unit);
50
Removed:
item.classList.remove("is-good", "is-low", "is-high", "is-unknown");
92
Added:
item.classList.remove("is-good", "is-fair", "is-poor", "is-unknown");
51
93
item.classList.add(`is-${state}`);
52
Removed:
item.querySelector("[data-reading-quick-marker]").style.left = `${percent}%`;
94
Added:
95
Added:
const marker = item.querySelector("[data-reading-quick-marker]");
96
Added:
marker.style.left = `${percent}%`;
97
Added:
marker.style.backgroundColor = Number.isFinite(value) ? markerColor(quality) : "";
98
Added:
53
99
const meter = item.querySelector(".reading-range");
54
100
meter.setAttribute("aria-valuenow", Math.round(percent).toString());
55
Removed:
meter.setAttribute("aria-label", `${label} ${readingStateLabel(state)} at ${formatValue(value, range)} ${unit}`);
101
Added:
meter.setAttribute("aria-label", `${label} ${qualityLabel(quality)} at ${formatValue(value, range)} ${unit}`);
56
102
item.querySelector("[data-reading-quick-value]").textContent = Number.isFinite(value)
57
Removed:
? `${formatValue(value, range)} ${unit} - ${readingStateLabel(state)} - ${age}`
103
Added:
? `${formatValue(value, range)} ${unit} - ${qualityLabel(quality)} - ${age}`
58
104
: "No reading yet";
59
105
}
60
106
roles/dashboard/templates/dashboard/index.html.ep
@@ -38,7 +38,7 @@
38
38
</div>
39
39
<div class="reading-range-labels d-grid gap-1 small text-muted">
40
40
<span data-reading-quick-min></span>
41
Removed:
<span class="text-success fw-bold text-center" data-reading-quick-target></span>
41
Added:
<span class="fw-bold text-center" data-reading-quick-target></span>
42
42
<span class="text-end" data-reading-quick-max></span>
43
43
</div>
44
44
</div>