feat add graph trendlines

Add thin red least-squares trendlines to probe charts so trends remain visible across missing readings.

Commit
ed1bbe54212e3eede04c9934f8d52d410571ae36
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
roles/dashboard/public/js/dashboard/charts.js
index b7935273..e4da7c34 100644..100644
@@ -35,7 +35,7 @@
35 35 }
36 36
37 37 function chartSeries(rows, timeframe) {
38 Removed: const series = { labels: [], timestamps: [], lower: [], upper: [], trend: [] };
38 Added: const series = { labels: [], timestamps: [], lower: [], upper: [], trend: [], trendline: [] };
39 39
40 40 rows.forEach(row => {
41 41 if (row.gap && series.labels.length) {
@@ -44,6 +44,7 @@
44 44 series.lower.push(null);
45 45 series.upper.push(null);
46 46 series.trend.push(null);
47 Added: series.trendline.push(null);
47 48 }
48 49
49 50 series.labels.push(formatChartTime(row.timestamp, timeframe));
@@ -53,9 +54,39 @@
53 54 series.trend.push(row.value === null ? null : Number(row.value));
54 55 });
55 56
57 Added: series.trendline = linearTrendline(series.timestamps, series.trend);
56 58 return series;
57 59 }
58 60
61 Added: function linearTrendline(timestamps, values) {
62 Added: const points = values.flatMap((value, index) => {
63 Added: const timestamp = Date.parse(timestamps[index]);
64 Added: return Number.isFinite(value) && Number.isFinite(timestamp)
65 Added: ? [{ timestamp, value }]
66 Added: : [];
67 Added: });
68 Added:
69 Added: if (points.length < 2) return timestamps.map(() => null);
70 Added:
71 Added: const origin = points[0].timestamp;
72 Added: const meanX = points.reduce((sum, point) => sum + point.timestamp - origin, 0) / points.length;
73 Added: const meanY = points.reduce((sum, point) => sum + point.value, 0) / points.length;
74 Added: const variance = points.reduce((sum, point) => {
75 Added: const x = point.timestamp - origin - meanX;
76 Added: return sum + x * x;
77 Added: }, 0);
78 Added: const covariance = points.reduce((sum, point) => {
79 Added: const x = point.timestamp - origin - meanX;
80 Added: return sum + x * (point.value - meanY);
81 Added: }, 0);
82 Added: const slope = variance ? covariance / variance : 0;
83 Added:
84 Added: return timestamps.map(timestamp => {
85 Added: const x = Date.parse(timestamp);
86 Added: return Number.isFinite(x) ? meanY + slope * (x - origin - meanX) : null;
87 Added: });
88 Added: }
89 Added:
59 90 function buildMessageCell(text) {
60 91 const cell = document.createElement("td");
61 92 cell.textContent = text;
@@ -137,6 +168,15 @@
137 168 pointRadius: 0,
138 169 pointHoverRadius: 0,
139 170 tooltipUnit: trendUnit
171 Added: },
172 Added: {
173 Added: label: "Linear trend",
174 Added: data: series.trendline,
175 Added: borderColor: "#dc2626",
176 Added: borderWidth: 1,
177 Added: pointRadius: 0,
178 Added: pointHoverRadius: 0,
179 Added: spanGaps: true
140 180 }
141 181 ]
142 182 },
@@ -191,7 +231,12 @@
191 231 .reverse()
192 232 .find(row => row.value !== null && Number.isFinite(Number(row.value)));
193 233 const unit = [...data.series].reverse().find(row => row.unit)?.unit || "";
194 Removed: const yBounds = yAxisBounds(probe, [...series.lower, ...series.upper, ...series.trend]);
234 Added: const yBounds = yAxisBounds(probe, [
235 Added: ...series.lower,
236 Added: ...series.upper,
237 Added: ...series.trend,
238 Added: ...series.trendline
239 Added: ]);
195 240 const trendLabel = `${probe} ${unit ? `(${unit})` : ""} trend`;
196 241
197 242 if (!charts.has(probe)) {
@@ -205,6 +250,7 @@
205 250 chart.data.datasets[2].data = series.trend;
206 251 chart.data.datasets[2].label = trendLabel;
207 252 chart.data.datasets[2].tooltipUnit = unit;
253 Added: chart.data.datasets[3].data = series.trendline;
208 254 chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[timeframe.selected()].ticks;
209 255 chart.options.scales.y.min = yBounds.min;
210 256 chart.options.scales.y.max = yBounds.max;