chartjs-plugin-trendline 3.2.0 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +40 -40
- package/.github/workflows/release.yml +64 -61
- package/.github/workflows/tests.yml +26 -26
- package/.prettierrc +5 -5
- package/CLAUDE.md +44 -44
- package/GEMINI.md +40 -40
- package/LICENSE +21 -21
- package/MIGRATION.md +126 -126
- package/README.md +166 -166
- package/babel.config.js +3 -3
- package/changelog.md +39 -39
- package/dist/chartjs-plugin-trendline.cjs +884 -885
- package/dist/chartjs-plugin-trendline.esm.js +882 -883
- package/dist/chartjs-plugin-trendline.js +890 -891
- package/dist/chartjs-plugin-trendline.min.js +8 -8
- package/dist/chartjs-plugin-trendline.min.js.map +1 -1
- package/example/barChart.html +165 -165
- package/example/barChartWithNullValues.html +168 -168
- package/example/barChart_label.html +174 -174
- package/example/exponentialChart.html +244 -244
- package/example/lineChart.html +210 -210
- package/example/lineChartProjection.html +261 -261
- package/example/lineChartTypeTime.html +190 -190
- package/example/scatterChart.html +136 -136
- package/example/scatterProjection.html +141 -141
- package/example/test-null-handling.html +59 -59
- package/index.html +215 -215
- package/jest.config.js +4 -4
- package/package.json +45 -40
- package/rollup.config.js +54 -54
- package/src/components/label.js +56 -56
- package/src/components/label.test.js +129 -129
- package/src/components/trendline.js +375 -375
- package/src/components/trendline.test.js +789 -789
- package/src/core/plugin.js +78 -79
- package/src/core/plugin.test.js +307 -0
- package/src/index.js +12 -12
- package/src/utils/drawing.js +125 -125
- package/src/utils/drawing.test.js +308 -308
- package/src/utils/exponentialFitter.js +146 -146
- package/src/utils/exponentialFitter.test.js +362 -362
- package/src/utils/lineFitter.js +86 -86
- package/src/utils/lineFitter.test.js +340 -340
|
@@ -1,883 +1,882 @@
|
|
|
1
|
-
/*!
|
|
2
|
-
* chartjs-plugin-trendline v3.2.
|
|
3
|
-
* https://github.com/Makanz/chartjs-plugin-trendline
|
|
4
|
-
* (c)
|
|
5
|
-
* Released under the MIT license
|
|
6
|
-
*/
|
|
7
|
-
/**
|
|
8
|
-
* A class that fits a line to a series of points using least squares.
|
|
9
|
-
*/
|
|
10
|
-
class LineFitter {
|
|
11
|
-
constructor() {
|
|
12
|
-
this.count = 0;
|
|
13
|
-
this.sumx = 0;
|
|
14
|
-
this.sumy = 0;
|
|
15
|
-
this.sumx2 = 0;
|
|
16
|
-
this.sumxy = 0;
|
|
17
|
-
this.minx = Number.MAX_VALUE;
|
|
18
|
-
this.maxx = Number.MIN_VALUE;
|
|
19
|
-
this._cachedSlope = null;
|
|
20
|
-
this._cachedIntercept = null;
|
|
21
|
-
this._cacheValid = false;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Adds a point to the line fitter.
|
|
26
|
-
* @param {number} x - The x-coordinate of the point.
|
|
27
|
-
* @param {number} y - The y-coordinate of the point.
|
|
28
|
-
*/
|
|
29
|
-
add(x, y) {
|
|
30
|
-
this.sumx += x;
|
|
31
|
-
this.sumy += y;
|
|
32
|
-
this.sumx2 += x * x;
|
|
33
|
-
this.sumxy += x * y;
|
|
34
|
-
if (x < this.minx) this.minx = x;
|
|
35
|
-
if (x > this.maxx) this.maxx = x;
|
|
36
|
-
this.count++;
|
|
37
|
-
this._cacheValid = false;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Calculates the slope of the fitted line.
|
|
42
|
-
* @returns {number} - The slope of the line.
|
|
43
|
-
*/
|
|
44
|
-
slope() {
|
|
45
|
-
if (!this._cacheValid) {
|
|
46
|
-
this._computeCoefficients();
|
|
47
|
-
}
|
|
48
|
-
return this._cachedSlope;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Calculates the y-intercept of the fitted line.
|
|
53
|
-
* @returns {number} - The y-intercept of the line.
|
|
54
|
-
*/
|
|
55
|
-
intercept() {
|
|
56
|
-
if (!this._cacheValid) {
|
|
57
|
-
this._computeCoefficients();
|
|
58
|
-
}
|
|
59
|
-
return this._cachedIntercept;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Returns the fitted value (y) for a given x.
|
|
64
|
-
* @param {number} x - The x-coordinate.
|
|
65
|
-
* @returns {number} - The corresponding y-coordinate on the fitted line.
|
|
66
|
-
*/
|
|
67
|
-
f(x) {
|
|
68
|
-
return this.slope() * x + this.intercept();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Calculates the projection of the line for the future value.
|
|
73
|
-
* @returns {number} - The future value based on the fitted line.
|
|
74
|
-
*/
|
|
75
|
-
fo() {
|
|
76
|
-
return -this.intercept() / this.slope();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Returns the scale (variance) of the fitted line.
|
|
81
|
-
* @returns {number} - The scale of the fitted line.
|
|
82
|
-
*/
|
|
83
|
-
scale() {
|
|
84
|
-
return this.slope();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
_computeCoefficients() {
|
|
88
|
-
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
89
|
-
this._cachedSlope = (this.count * this.sumxy - this.sumx * this.sumy) / denominator;
|
|
90
|
-
this._cachedIntercept = (this.sumy - this._cachedSlope * this.sumx) / this.count;
|
|
91
|
-
this._cacheValid = true;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* A class that fits an exponential curve to a series of points using least squares.
|
|
97
|
-
* Fits y = a * e^(b*x) by transforming to ln(y) = ln(a) + b*x
|
|
98
|
-
*/
|
|
99
|
-
class ExponentialFitter {
|
|
100
|
-
constructor() {
|
|
101
|
-
this.count = 0;
|
|
102
|
-
this.sumx = 0;
|
|
103
|
-
this.sumlny = 0;
|
|
104
|
-
this.sumx2 = 0;
|
|
105
|
-
this.sumxlny = 0;
|
|
106
|
-
this.minx = Number.MAX_VALUE;
|
|
107
|
-
this.maxx = Number.MIN_VALUE;
|
|
108
|
-
this.hasValidData = true;
|
|
109
|
-
this.dataPoints = []; // Store data points for correlation calculation
|
|
110
|
-
this._cachedGrowthRate = null;
|
|
111
|
-
this._cachedCoefficient = null;
|
|
112
|
-
this._cachedCorrelation = null;
|
|
113
|
-
this._cacheValid = false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Adds a point to the exponential fitter.
|
|
118
|
-
* @param {number} x - The x-coordinate of the point.
|
|
119
|
-
* @param {number} y - The y-coordinate of the point.
|
|
120
|
-
*/
|
|
121
|
-
add(x, y) {
|
|
122
|
-
if (y <= 0) {
|
|
123
|
-
this.hasValidData = false;
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const lny = Math.log(y);
|
|
128
|
-
if (!isFinite(lny)) {
|
|
129
|
-
this.hasValidData = false;
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
this.sumx += x;
|
|
134
|
-
this.sumlny += lny;
|
|
135
|
-
this.sumx2 += x * x;
|
|
136
|
-
this.sumxlny += x * lny;
|
|
137
|
-
if (x < this.minx) this.minx = x;
|
|
138
|
-
if (x > this.maxx) this.maxx = x;
|
|
139
|
-
this.dataPoints.push({x, y, lny}); // Store actual data points
|
|
140
|
-
this.count++;
|
|
141
|
-
this._cacheValid = false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Calculates the exponential growth rate (b in y = a * e^(b*x)).
|
|
146
|
-
* @returns {number} - The exponential growth rate.
|
|
147
|
-
*/
|
|
148
|
-
growthRate() {
|
|
149
|
-
if (!this.hasValidData || this.count < 2) return 0;
|
|
150
|
-
if (!this._cacheValid) {
|
|
151
|
-
this._computeCoefficients();
|
|
152
|
-
}
|
|
153
|
-
return this._cachedGrowthRate;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Calculates the exponential coefficient (a in y = a * e^(b*x)).
|
|
158
|
-
* @returns {number} - The exponential coefficient.
|
|
159
|
-
*/
|
|
160
|
-
coefficient() {
|
|
161
|
-
if (!this.hasValidData || this.count < 2) return 1;
|
|
162
|
-
if (!this._cacheValid) {
|
|
163
|
-
this._computeCoefficients();
|
|
164
|
-
}
|
|
165
|
-
return this._cachedCoefficient;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Returns the fitted exponential value (y) for a given x.
|
|
170
|
-
* @param {number} x - The x-coordinate.
|
|
171
|
-
* @returns {number} - The corresponding y-coordinate on the fitted exponential curve.
|
|
172
|
-
*/
|
|
173
|
-
f(x) {
|
|
174
|
-
if (!this.hasValidData || this.count < 2) return 0;
|
|
175
|
-
if (!this._cacheValid) {
|
|
176
|
-
this._computeCoefficients();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Check for potential overflow before calculation
|
|
180
|
-
if (Math.abs(this._cachedGrowthRate * x) > 500) return 0; // Safer limit to prevent overflow
|
|
181
|
-
|
|
182
|
-
const result = this._cachedCoefficient * Math.exp(this._cachedGrowthRate * x);
|
|
183
|
-
return isFinite(result) ? result : 0;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Calculates the correlation coefficient (R-squared) for the exponential fit.
|
|
188
|
-
* @returns {number} - The correlation coefficient (0-1).
|
|
189
|
-
*/
|
|
190
|
-
correlation() {
|
|
191
|
-
if (!this.hasValidData || this.count < 2) return 0;
|
|
192
|
-
if (!this._cacheValid) {
|
|
193
|
-
this._computeCoefficients();
|
|
194
|
-
}
|
|
195
|
-
return this._cachedCorrelation;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Returns the scale (growth rate) of the fitted exponential curve.
|
|
200
|
-
* @returns {number} - The growth rate of the exponential curve.
|
|
201
|
-
*/
|
|
202
|
-
scale() {
|
|
203
|
-
return this.growthRate();
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
_computeCoefficients() {
|
|
207
|
-
if (!this.hasValidData || this.count < 2) {
|
|
208
|
-
this._cachedGrowthRate = 0;
|
|
209
|
-
this._cachedCoefficient = 1;
|
|
210
|
-
this._cachedCorrelation = 0;
|
|
211
|
-
this._cacheValid = true;
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
216
|
-
if (Math.abs(denominator) < 1e-10) {
|
|
217
|
-
this._cachedGrowthRate = 0;
|
|
218
|
-
this._cachedCoefficient = 1;
|
|
219
|
-
this._cachedCorrelation = 0;
|
|
220
|
-
this._cacheValid = true;
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
this._cachedGrowthRate = (this.count * this.sumxlny - this.sumx * this.sumlny) / denominator;
|
|
225
|
-
const lnA = (this.sumlny - this._cachedGrowthRate * this.sumx) / this.count;
|
|
226
|
-
this._cachedCoefficient = Math.exp(lnA);
|
|
227
|
-
|
|
228
|
-
const meanLnY = this.sumlny / this.count;
|
|
229
|
-
let ssTotal = 0;
|
|
230
|
-
let ssRes = 0;
|
|
231
|
-
|
|
232
|
-
for (const point of this.dataPoints) {
|
|
233
|
-
const predictedLnY = lnA + this._cachedGrowthRate * point.x;
|
|
234
|
-
ssTotal += Math.pow(point.lny - meanLnY, 2);
|
|
235
|
-
ssRes += Math.pow(point.lny - predictedLnY, 2);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
this._cachedCorrelation = ssTotal === 0 ? 1 : Math.max(0, 1 - (ssRes / ssTotal));
|
|
239
|
-
this._cacheValid = true;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Retrieves the x and y scales from the chart instance.
|
|
245
|
-
* @param {Chart} chartInstance - The chart instance.
|
|
246
|
-
* @returns {Object} - The xScale and yScale of the chart.
|
|
247
|
-
*/
|
|
248
|
-
const getScales = (chartInstance) => {
|
|
249
|
-
let xScale, yScale;
|
|
250
|
-
for (const scale of Object.values(chartInstance.scales)) {
|
|
251
|
-
if (scale.isHorizontal()) xScale = scale;
|
|
252
|
-
else yScale = scale;
|
|
253
|
-
if (xScale && yScale) break;
|
|
254
|
-
}
|
|
255
|
-
return { xScale, yScale };
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Sets the line style (dashed, dotted, solid) for the canvas context.
|
|
260
|
-
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
261
|
-
* @param {string} lineStyle - The style of the line ('dotted', 'dashed', 'solid', etc.).
|
|
262
|
-
*/
|
|
263
|
-
const setLineStyle = (ctx, lineStyle) => {
|
|
264
|
-
switch (lineStyle) {
|
|
265
|
-
case 'dotted':
|
|
266
|
-
ctx.setLineDash([2, 2]);
|
|
267
|
-
break;
|
|
268
|
-
case 'dashed':
|
|
269
|
-
ctx.setLineDash([8, 3]);
|
|
270
|
-
break;
|
|
271
|
-
case 'dashdot':
|
|
272
|
-
ctx.setLineDash([8, 3, 2, 3]);
|
|
273
|
-
break;
|
|
274
|
-
case 'solid':
|
|
275
|
-
default:
|
|
276
|
-
ctx.setLineDash([]);
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Draws the trendline on the canvas context.
|
|
283
|
-
* @param {Object} params - The trendline parameters.
|
|
284
|
-
* @param {CanvasRenderingContext2D} params.ctx - The canvas rendering context.
|
|
285
|
-
* @param {number} params.x1 - Starting x-coordinate of the trendline.
|
|
286
|
-
* @param {number} params.y1 - Starting y-coordinate of the trendline.
|
|
287
|
-
* @param {number} params.x2 - Ending x-coordinate of the trendline.
|
|
288
|
-
* @param {number} params.y2 - Ending y-coordinate of the trendline.
|
|
289
|
-
* @param {string} params.colorMin - The starting color of the trendline gradient.
|
|
290
|
-
* @param {string} params.colorMax - The ending color of the trendline gradient.
|
|
291
|
-
*/
|
|
292
|
-
const drawTrendline = ({ ctx, x1, y1, x2, y2, colorMin, colorMax }) => {
|
|
293
|
-
// Ensure all values are finite numbers
|
|
294
|
-
if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
|
|
295
|
-
console.warn(
|
|
296
|
-
'Cannot draw trendline: coordinates contain non-finite values',
|
|
297
|
-
{ x1, y1, x2, y2 }
|
|
298
|
-
);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
ctx.beginPath();
|
|
303
|
-
ctx.moveTo(x1, y1);
|
|
304
|
-
ctx.lineTo(x2, y2);
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
// Additional validation for degenerate gradients
|
|
308
|
-
const dx = x2 - x1;
|
|
309
|
-
const dy = y2 - y1;
|
|
310
|
-
const gradientLength = Math.sqrt(dx * dx + dy * dy);
|
|
311
|
-
|
|
312
|
-
// If the gradient vector is too small, createLinearGradient may fail
|
|
313
|
-
if (gradientLength < 0.01) {
|
|
314
|
-
console.warn('Gradient vector too small, using solid color:', { x1, y1, x2, y2, length: gradientLength });
|
|
315
|
-
ctx.strokeStyle = colorMin;
|
|
316
|
-
} else {
|
|
317
|
-
let gradient = ctx.createLinearGradient(x1, y1, x2, y2);
|
|
318
|
-
gradient.addColorStop(0, colorMin);
|
|
319
|
-
gradient.addColorStop(1, colorMax);
|
|
320
|
-
ctx.strokeStyle = gradient;
|
|
321
|
-
}
|
|
322
|
-
} catch (e) {
|
|
323
|
-
// Fallback to solid color if gradient creation fails
|
|
324
|
-
console.warn('Gradient creation failed, using solid color:', e);
|
|
325
|
-
ctx.strokeStyle = colorMin;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
ctx.stroke();
|
|
329
|
-
ctx.closePath();
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Fills the area below the trendline with the specified color.
|
|
334
|
-
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
335
|
-
* @param {number} x1 - Starting x-coordinate of the trendline.
|
|
336
|
-
* @param {number} y1 - Starting y-coordinate of the trendline.
|
|
337
|
-
* @param {number} x2 - Ending x-coordinate of the trendline.
|
|
338
|
-
* @param {number} y2 - Ending y-coordinate of the trendline.
|
|
339
|
-
* @param {number} drawBottom - The bottom boundary of the chart.
|
|
340
|
-
* @param {string} fillColor - The color to fill below the trendline.
|
|
341
|
-
*/
|
|
342
|
-
const fillBelowTrendline = (ctx, x1, y1, x2, y2, drawBottom, fillColor) => {
|
|
343
|
-
// Ensure all values are finite numbers
|
|
344
|
-
if (
|
|
345
|
-
!isFinite(x1) ||
|
|
346
|
-
!isFinite(y1) ||
|
|
347
|
-
!isFinite(x2) ||
|
|
348
|
-
!isFinite(y2) ||
|
|
349
|
-
!isFinite(drawBottom)
|
|
350
|
-
) {
|
|
351
|
-
console.warn(
|
|
352
|
-
'Cannot fill below trendline: coordinates contain non-finite values',
|
|
353
|
-
{ x1, y1, x2, y2, drawBottom }
|
|
354
|
-
);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
ctx.beginPath();
|
|
359
|
-
ctx.moveTo(x1, y1);
|
|
360
|
-
ctx.lineTo(x2, y2);
|
|
361
|
-
ctx.lineTo(x2, drawBottom);
|
|
362
|
-
ctx.lineTo(x1, drawBottom);
|
|
363
|
-
ctx.lineTo(x1, y1);
|
|
364
|
-
ctx.closePath();
|
|
365
|
-
|
|
366
|
-
ctx.fillStyle = fillColor;
|
|
367
|
-
ctx.fill();
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Adds a label to the trendline at the calculated angle.
|
|
372
|
-
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
373
|
-
* @param {string} label - The label text to add.
|
|
374
|
-
* @param {number} x1 - The starting x-coordinate of the trendline.
|
|
375
|
-
* @param {number} y1 - The starting y-coordinate of the trendline.
|
|
376
|
-
* @param {number} x2 - The ending x-coordinate of the trendline.
|
|
377
|
-
* @param {number} y2 - The ending y-coordinate of the trendline.
|
|
378
|
-
* @param {number} angle - The angle (in radians) of the trendline.
|
|
379
|
-
* @param {string} labelColor - The color of the label text.
|
|
380
|
-
* @param {string} family - The font family for the label text.
|
|
381
|
-
* @param {number} size - The font size for the label text.
|
|
382
|
-
* @param {number} offset - The offset of the label from the trendline
|
|
383
|
-
*/
|
|
384
|
-
const addTrendlineLabel = (
|
|
385
|
-
ctx,
|
|
386
|
-
label,
|
|
387
|
-
x1,
|
|
388
|
-
y1,
|
|
389
|
-
x2,
|
|
390
|
-
y2,
|
|
391
|
-
angle,
|
|
392
|
-
labelColor,
|
|
393
|
-
family,
|
|
394
|
-
size,
|
|
395
|
-
offset
|
|
396
|
-
) => {
|
|
397
|
-
// Set the label font and color
|
|
398
|
-
ctx.font = `${size}px ${family}`;
|
|
399
|
-
ctx.fillStyle = labelColor;
|
|
400
|
-
|
|
401
|
-
// Label width
|
|
402
|
-
const labelWidth = ctx.measureText(label).width;
|
|
403
|
-
|
|
404
|
-
// Calculate the center of the trendline
|
|
405
|
-
const labelX = (x1 + x2) / 2;
|
|
406
|
-
const labelY = (y1 + y2) / 2;
|
|
407
|
-
|
|
408
|
-
// Save the current state of the canvas
|
|
409
|
-
ctx.save();
|
|
410
|
-
|
|
411
|
-
// Translate to the label position
|
|
412
|
-
ctx.translate(labelX, labelY);
|
|
413
|
-
|
|
414
|
-
// Rotate the context to align with the trendline
|
|
415
|
-
ctx.rotate(angle);
|
|
416
|
-
|
|
417
|
-
// Adjust for the length of the label and rotation
|
|
418
|
-
const adjustedX = -labelWidth / 2; // Center the label horizontally
|
|
419
|
-
const adjustedY = offset; // Adjust Y to compensate for the height
|
|
420
|
-
|
|
421
|
-
// Draw the label
|
|
422
|
-
ctx.fillText(label, adjustedX, adjustedY);
|
|
423
|
-
|
|
424
|
-
// Restore the canvas state
|
|
425
|
-
ctx.restore();
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Adds a trendline (fitter) to the dataset on the chart and optionally labels it with trend value.
|
|
430
|
-
* @param {Object} datasetMeta - Metadata about the dataset.
|
|
431
|
-
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
432
|
-
* @param {Object} dataset - The dataset configuration from the chart.
|
|
433
|
-
* @param {Scale} xScale - The x-axis scale object.
|
|
434
|
-
* @param {Scale} yScale - The y-axis scale object.
|
|
435
|
-
*/
|
|
436
|
-
const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
|
|
437
|
-
const yAxisID = dataset.yAxisID || 'y'; // Default to 'y' if no yAxisID is specified
|
|
438
|
-
const yScaleToUse = datasetMeta.controller.chart.scales[yAxisID] || yScale;
|
|
439
|
-
|
|
440
|
-
// Determine if we're using exponential or linear trendline
|
|
441
|
-
const isExponential = !!dataset.trendlineExponential;
|
|
442
|
-
const trendlineConfig = dataset.trendlineExponential || dataset.trendlineLinear || {};
|
|
443
|
-
|
|
444
|
-
const defaultColor = dataset.borderColor || 'rgba(169,169,169, .6)';
|
|
445
|
-
const {
|
|
446
|
-
colorMin = defaultColor,
|
|
447
|
-
colorMax = defaultColor,
|
|
448
|
-
width: lineWidth = dataset.borderWidth || 3,
|
|
449
|
-
lineStyle = 'solid',
|
|
450
|
-
fillColor = false,
|
|
451
|
-
// trendoffset is now handled separately
|
|
452
|
-
} = trendlineConfig;
|
|
453
|
-
let trendoffset = trendlineConfig.trendoffset || 0;
|
|
454
|
-
|
|
455
|
-
const {
|
|
456
|
-
color = defaultColor,
|
|
457
|
-
text = isExponential ? 'Exponential Trendline' : 'Trendline',
|
|
458
|
-
display = true,
|
|
459
|
-
displayValue = true,
|
|
460
|
-
offset = 10,
|
|
461
|
-
percentage = false,
|
|
462
|
-
} = (trendlineConfig && trendlineConfig.label) || {};
|
|
463
|
-
|
|
464
|
-
const {
|
|
465
|
-
family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
|
|
466
|
-
size = 12,
|
|
467
|
-
} = (trendlineConfig && trendlineConfig.label && trendlineConfig.label.font) || {};
|
|
468
|
-
|
|
469
|
-
const chartOptions = datasetMeta.controller.chart.options;
|
|
470
|
-
const parsingOptions =
|
|
471
|
-
typeof chartOptions.parsing === 'object'
|
|
472
|
-
? chartOptions.parsing
|
|
473
|
-
: undefined;
|
|
474
|
-
const xAxisKey =
|
|
475
|
-
trendlineConfig?.xAxisKey || parsingOptions?.xAxisKey || 'x';
|
|
476
|
-
const yAxisKey =
|
|
477
|
-
trendlineConfig?.yAxisKey || parsingOptions?.yAxisKey || 'y';
|
|
478
|
-
|
|
479
|
-
let fitter = isExponential ? new ExponentialFitter() : new LineFitter();
|
|
480
|
-
|
|
481
|
-
// --- Data Point Collection and Validation for LineFitter ---
|
|
482
|
-
|
|
483
|
-
// Sanitize trendoffset: if its absolute value is too large, reset to 0.
|
|
484
|
-
// This prevents errors if offset is out of bounds of the dataset length.
|
|
485
|
-
if (Math.abs(trendoffset) >= dataset.data.length) trendoffset = 0;
|
|
486
|
-
|
|
487
|
-
// Determine the actual starting index for data processing if a positive trendoffset is applied.
|
|
488
|
-
// This skips initial data points and finds the first non-null data point thereafter.
|
|
489
|
-
// `effectiveFirstIndex` is used to determine the data type ('xy' or array) and to skip initial points for positive offset.
|
|
490
|
-
let effectiveFirstIndex = 0;
|
|
491
|
-
if (trendoffset > 0) {
|
|
492
|
-
// Start searching for a non-null point from the offset.
|
|
493
|
-
const firstNonNullAfterOffset = dataset.data.slice(trendoffset).findIndex((d) => d !== undefined && d !== null);
|
|
494
|
-
if (firstNonNullAfterOffset !== -1) {
|
|
495
|
-
effectiveFirstIndex = trendoffset + firstNonNullAfterOffset;
|
|
496
|
-
} else {
|
|
497
|
-
// All points after the offset are null or undefined, so effectively no data for trendline.
|
|
498
|
-
effectiveFirstIndex = dataset.data.length;
|
|
499
|
-
}
|
|
500
|
-
} else {
|
|
501
|
-
// For zero or negative offset, the initial search for 'xy' type detection starts from the beginning of the dataset.
|
|
502
|
-
// The actual exclusion of points for negative offset (from the end) is handled per-point within the loop.
|
|
503
|
-
const firstNonNull = dataset.data.findIndex((d) => d !== undefined && d !== null);
|
|
504
|
-
if (firstNonNull !== -1) {
|
|
505
|
-
effectiveFirstIndex = firstNonNull;
|
|
506
|
-
} else {
|
|
507
|
-
// All data in the dataset is null or undefined.
|
|
508
|
-
effectiveFirstIndex = dataset.data.length;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Determine data structure type (object {x,y} or array of numbers) based on the first valid data point.
|
|
513
|
-
// This informs how `xAxisKey` and `yAxisKey` are used or if `index` is used for x-values.
|
|
514
|
-
let xy = effectiveFirstIndex < dataset.data.length && typeof dataset.data[effectiveFirstIndex] === 'object';
|
|
515
|
-
|
|
516
|
-
// Iterate over dataset to collect points for the LineFitter.
|
|
517
|
-
dataset.data.forEach((data, index) => {
|
|
518
|
-
// Skip any data point that is null or undefined directly. This is a general guard.
|
|
519
|
-
if (data == null) return;
|
|
520
|
-
|
|
521
|
-
// Apply trendoffset logic for including/excluding points:
|
|
522
|
-
// 1. Positive offset: Skip data points if their index is before the `effectiveFirstIndex`.
|
|
523
|
-
// `effectiveFirstIndex` already accounts for the offset and initial nulls.
|
|
524
|
-
if (trendoffset > 0 && index < effectiveFirstIndex) return;
|
|
525
|
-
// 2. Negative offset: Skip data points if their index is at or after the calculated end point.
|
|
526
|
-
// `dataset.data.length + trendoffset` marks the first index of the points to be excluded from the end.
|
|
527
|
-
// For example, if length is 10 and offset is -2, points from index 8 onwards are skipped.
|
|
528
|
-
if (trendoffset < 0 && index >= dataset.data.length + trendoffset) return;
|
|
529
|
-
|
|
530
|
-
// Process data based on scale type and data structure.
|
|
531
|
-
if (['time', 'timeseries'].includes(xScale.options.type) && xy) {
|
|
532
|
-
// For time-based scales with object data, convert x to a numerical timestamp; ensure y is a valid number.
|
|
533
|
-
let x = data[xAxisKey] != null ? data[xAxisKey] : data.t; // `data.t` is a Chart.js internal fallback for time data.
|
|
534
|
-
const yValue = data[yAxisKey];
|
|
535
|
-
|
|
536
|
-
// Both x and y must be valid for the point to be included.
|
|
537
|
-
if (x != null && x !== undefined && yValue != null && !isNaN(yValue)) {
|
|
538
|
-
fitter.add(new Date(x).getTime(), yValue);
|
|
539
|
-
}
|
|
540
|
-
// If x or yValue is invalid, the point is skipped.
|
|
541
|
-
} else if (xy) { // Data is identified as array of objects {x,y}.
|
|
542
|
-
const xVal = data[xAxisKey];
|
|
543
|
-
const yVal = data[yAxisKey];
|
|
544
|
-
|
|
545
|
-
const xIsValid = xVal != null && !isNaN(xVal);
|
|
546
|
-
const yIsValid = yVal != null && !isNaN(yVal);
|
|
547
|
-
|
|
548
|
-
// Both xVal and yVal must be valid numbers to include the point.
|
|
549
|
-
if (xIsValid && yIsValid) {
|
|
550
|
-
fitter.add(xVal, yVal);
|
|
551
|
-
}
|
|
552
|
-
// If either xVal or yVal is invalid, the point is skipped. No fallback to using index.
|
|
553
|
-
} else if (['time', 'timeseries'].includes(xScale.options.type) && !xy) {
|
|
554
|
-
// For time-based scales with array of numbers, get the x-value from the chart labels
|
|
555
|
-
const chartLabels = datasetMeta.controller.chart.data.labels;
|
|
556
|
-
if (chartLabels && chartLabels[index] && data != null && !isNaN(data)) {
|
|
557
|
-
const timeValue = new Date(chartLabels[index]).getTime();
|
|
558
|
-
if (!isNaN(timeValue)) {
|
|
559
|
-
fitter.add(timeValue, data);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
} else {
|
|
563
|
-
// Data is an array of numbers (or other non-object types).
|
|
564
|
-
// The 'data' variable itself is the y-value, and 'index' is the x-value.
|
|
565
|
-
// We still need to check for null/NaN here because 'data' (the y-value) could be null/NaN
|
|
566
|
-
// even if the entry 'data' (the point/container) wasn't null in the initial check.
|
|
567
|
-
// This applies if dataset.data = [1, 2, null, 4].
|
|
568
|
-
if (data != null && !isNaN(data)) {
|
|
569
|
-
fitter.add(index, data);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
// --- Trendline Coordinate Calculation ---
|
|
575
|
-
// Ensure there are enough points to form a trendline.
|
|
576
|
-
if (fitter.count < 2) {
|
|
577
|
-
return; // Not enough data points to calculate a trendline.
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// These variables will hold the pixel coordinates for drawing the trendline.
|
|
581
|
-
let x1_px, y1_px, x2_px, y2_px;
|
|
582
|
-
|
|
583
|
-
const chartArea = datasetMeta.controller.chart.chartArea; // Defines the drawable area in pixels.
|
|
584
|
-
|
|
585
|
-
// Determine trendline start/end points based on the 'projection' option.
|
|
586
|
-
if (trendlineConfig.projection) {
|
|
587
|
-
let points = [];
|
|
588
|
-
|
|
589
|
-
if (isExponential) {
|
|
590
|
-
// For exponential curves, we generate points across the x-axis range
|
|
591
|
-
const val_x_left = xScale.getValueForPixel(chartArea.left);
|
|
592
|
-
const y_at_left = fitter.f(val_x_left);
|
|
593
|
-
points.push({ x: val_x_left, y: y_at_left });
|
|
594
|
-
|
|
595
|
-
const val_x_right = xScale.getValueForPixel(chartArea.right);
|
|
596
|
-
const y_at_right = fitter.f(val_x_right);
|
|
597
|
-
points.push({ x: val_x_right, y: y_at_right });
|
|
598
|
-
} else {
|
|
599
|
-
// Linear projection logic (existing code)
|
|
600
|
-
const slope = fitter.slope();
|
|
601
|
-
const intercept = fitter.intercept();
|
|
602
|
-
|
|
603
|
-
if (Math.abs(slope) > 1e-6) {
|
|
604
|
-
const val_y_top = yScaleToUse.getValueForPixel(chartArea.top);
|
|
605
|
-
const x_at_top = (val_y_top - intercept) / slope;
|
|
606
|
-
points.push({ x: x_at_top, y: val_y_top });
|
|
607
|
-
|
|
608
|
-
const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom);
|
|
609
|
-
const x_at_bottom = (val_y_bottom - intercept) / slope;
|
|
610
|
-
points.push({ x: x_at_bottom, y: val_y_bottom });
|
|
611
|
-
} else {
|
|
612
|
-
points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept});
|
|
613
|
-
points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept});
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const val_x_left = xScale.getValueForPixel(chartArea.left);
|
|
617
|
-
const y_at_left = fitter.f(val_x_left);
|
|
618
|
-
points.push({ x: val_x_left, y: y_at_left });
|
|
619
|
-
|
|
620
|
-
const val_x_right = xScale.getValueForPixel(chartArea.right);
|
|
621
|
-
const y_at_right = fitter.f(val_x_right);
|
|
622
|
-
points.push({ x: val_x_right, y: y_at_right });
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const chartMinX = xScale.getValueForPixel(chartArea.left);
|
|
626
|
-
const chartMaxX = xScale.getValueForPixel(chartArea.right);
|
|
627
|
-
|
|
628
|
-
const yValsFromPixels = [yScaleToUse.getValueForPixel(chartArea.top), yScaleToUse.getValueForPixel(chartArea.bottom)];
|
|
629
|
-
const finiteYVals = yValsFromPixels.filter(y => isFinite(y));
|
|
630
|
-
// Ensure actualChartMinY and actualChartMaxY are correctly ordered for the filter
|
|
631
|
-
const actualChartMinY = finiteYVals.length > 0 ? Math.min(...finiteYVals) : -Infinity;
|
|
632
|
-
const actualChartMaxY = finiteYVals.length > 0 ? Math.max(...finiteYVals) : Infinity;
|
|
633
|
-
|
|
634
|
-
let validPoints = points.filter(p =>
|
|
635
|
-
isFinite(p.x) && isFinite(p.y) &&
|
|
636
|
-
p.x >= chartMinX && p.x <= chartMaxX && p.y >= actualChartMinY && p.y <= actualChartMaxY
|
|
637
|
-
);
|
|
638
|
-
|
|
639
|
-
validPoints = validPoints.filter((point, index, self) =>
|
|
640
|
-
index === self.findIndex((t) => (
|
|
641
|
-
Math.abs(t.x - point.x) < 1e-4 && Math.abs(t.y - point.y) < 1e-4
|
|
642
|
-
))
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
if (validPoints.length >= 2) {
|
|
646
|
-
validPoints.sort((a,b) => a.x - b.x || a.y - b.y);
|
|
647
|
-
|
|
648
|
-
x1_px = xScale.getPixelForValue(validPoints[0].x);
|
|
649
|
-
y1_px = yScaleToUse.getPixelForValue(validPoints[0].y);
|
|
650
|
-
x2_px = xScale.getPixelForValue(validPoints[validPoints.length - 1].x);
|
|
651
|
-
y2_px = yScaleToUse.getPixelForValue(validPoints[validPoints.length - 1].y);
|
|
652
|
-
} else {
|
|
653
|
-
x1_px = NaN; y1_px = NaN; x2_px = NaN; y2_px = NaN;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
} else {
|
|
657
|
-
const y_at_minx = fitter.f(fitter.minx);
|
|
658
|
-
const y_at_maxx = fitter.f(fitter.maxx);
|
|
659
|
-
|
|
660
|
-
x1_px = xScale.getPixelForValue(fitter.minx);
|
|
661
|
-
y1_px = yScaleToUse.getPixelForValue(y_at_minx);
|
|
662
|
-
x2_px = xScale.getPixelForValue(fitter.maxx);
|
|
663
|
-
y2_px = yScaleToUse.getPixelForValue(y_at_maxx);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// --- Line Clipping and Drawing ---
|
|
667
|
-
let clippedCoords = null;
|
|
668
|
-
if (isFinite(x1_px) && isFinite(y1_px) && isFinite(x2_px) && isFinite(y2_px)) {
|
|
669
|
-
clippedCoords = liangBarskyClip(x1_px, y1_px, x2_px, y2_px, chartArea);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (clippedCoords) {
|
|
673
|
-
x1_px = clippedCoords.x1;
|
|
674
|
-
y1_px = clippedCoords.y1;
|
|
675
|
-
x2_px = clippedCoords.x2;
|
|
676
|
-
y2_px = clippedCoords.y2;
|
|
677
|
-
|
|
678
|
-
if (Math.abs(x1_px - x2_px) < 0.5 && Math.abs(y1_px - y2_px) < 0.5) ; else {
|
|
679
|
-
ctx.lineWidth = lineWidth;
|
|
680
|
-
setLineStyle(ctx, lineStyle);
|
|
681
|
-
drawTrendline({ ctx, x1: x1_px, y1: y1_px, x2: x2_px, y2: y2_px, colorMin, colorMax });
|
|
682
|
-
|
|
683
|
-
if (fillColor) {
|
|
684
|
-
fillBelowTrendline(ctx, x1_px, y1_px, x2_px, y2_px, chartArea.bottom, fillColor);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const angle = Math.atan2(y2_px - y1_px, x2_px - x1_px);
|
|
688
|
-
|
|
689
|
-
if (trendlineConfig.label && display !== false) {
|
|
690
|
-
let trendText = text;
|
|
691
|
-
if (displayValue) {
|
|
692
|
-
if (isExponential) {
|
|
693
|
-
const coefficient = fitter.coefficient();
|
|
694
|
-
const growthRate = fitter.growthRate();
|
|
695
|
-
trendText = `${text} (a=${coefficient.toFixed(2)}, b=${growthRate.toFixed(2)})`;
|
|
696
|
-
} else {
|
|
697
|
-
const displaySlope = fitter.slope();
|
|
698
|
-
trendText = `${text} (Slope: ${
|
|
699
|
-
percentage
|
|
700
|
-
? (displaySlope * 100).toFixed(2) + '%'
|
|
701
|
-
: displaySlope.toFixed(2)
|
|
702
|
-
})`;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
addTrendlineLabel(
|
|
706
|
-
ctx,
|
|
707
|
-
trendText,
|
|
708
|
-
x1_px,
|
|
709
|
-
y1_px,
|
|
710
|
-
x2_px,
|
|
711
|
-
y2_px,
|
|
712
|
-
angle,
|
|
713
|
-
color,
|
|
714
|
-
family,
|
|
715
|
-
size,
|
|
716
|
-
offset
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Clips a line segment to a rectangular clipping window using the Liang-Barsky algorithm.
|
|
725
|
-
* This algorithm is efficient for 2D line clipping against an axis-aligned rectangle.
|
|
726
|
-
* It determines the portion of the line segment (x1,y1)-(x2,y2) that is visible within
|
|
727
|
-
* the rectangle defined by chartArea {left, right, top, bottom}.
|
|
728
|
-
* @param {number} x1 - Pixel coordinate for the start of the line (x-axis).
|
|
729
|
-
* @param {number} y1 - Pixel coordinate for the start of the line (y-axis).
|
|
730
|
-
* @param {number} x2 - Pixel coordinate for the end of the line (x-axis).
|
|
731
|
-
* @param {number} y2 - Pixel coordinate for the end of the line (y-axis).
|
|
732
|
-
* @param {Object} chartArea - The chart area with { left, right, top, bottom } pixel boundaries.
|
|
733
|
-
* @returns {Object|null} An object with { x1, y1, x2, y2 } of the clipped line,
|
|
734
|
-
* or null if the line is entirely outside the window or clipped to effectively a point.
|
|
735
|
-
*/
|
|
736
|
-
function liangBarskyClip(x1, y1, x2, y2, chartArea) {
|
|
737
|
-
let dx = x2 - x1; // Change in x
|
|
738
|
-
let dy = y2 - y1; // Change in y
|
|
739
|
-
let t0 = 0.0; // Parameter for the start of the clipped line segment (initially at x1, y1).
|
|
740
|
-
// Represents the proportion along the line from (x1,y1) to (x2,y2).
|
|
741
|
-
let t1 = 1.0; // Parameter for the end of the clipped line segment (initially at x2, y2).
|
|
742
|
-
|
|
743
|
-
// p and q arrays are used in the Liang-Barsky algorithm conditions.
|
|
744
|
-
// For each of the 4 clip edges (left, right, top, bottom):
|
|
745
|
-
// p[k] * t >= q[k]
|
|
746
|
-
// p values: -dx (left), dx (right), -dy (top), dy (bottom)
|
|
747
|
-
// q values: x1 - x_min (left), x_max - x1 (right), y1 - y_min (top), y_max - y1 (bottom)
|
|
748
|
-
// Note: Canvas y-coordinates increase downwards, so chartArea.top < chartArea.bottom.
|
|
749
|
-
const p = [-dx, dx, -dy, dy];
|
|
750
|
-
const q = [
|
|
751
|
-
x1 - chartArea.left, // q[0] for left edge check
|
|
752
|
-
chartArea.right - x1, // q[1] for right edge check
|
|
753
|
-
y1 - chartArea.top, // q[2] for top edge check
|
|
754
|
-
chartArea.bottom - y1, // q[3] for bottom edge check
|
|
755
|
-
];
|
|
756
|
-
|
|
757
|
-
for (let i = 0; i < 4; i++) { // Iterate through the 4 clip edges (left, right, top, bottom).
|
|
758
|
-
if (p[i] === 0) { // Line is parallel to the i-th clipping edge.
|
|
759
|
-
if (q[i] < 0) { // Line is outside this parallel edge (e.g., for left edge, x1 < chartArea.left).
|
|
760
|
-
return null; // Line is completely outside, so reject.
|
|
761
|
-
}
|
|
762
|
-
// If q[i] >= 0, line is inside or on the parallel edge, so this edge doesn't clip it. Continue.
|
|
763
|
-
} else {
|
|
764
|
-
const r = q[i] / p[i]; // Parameter t where the line intersects this edge's infinite line.
|
|
765
|
-
if (p[i] < 0) {
|
|
766
|
-
// Line is potentially entering the clip region with respect to this edge.
|
|
767
|
-
// (e.g., for left edge, -dx < 0 means line goes from left to right, dx > 0).
|
|
768
|
-
// We want the largest t0 among all entry points.
|
|
769
|
-
if (r > t1) return null; // Line enters after it has already exited from another edge.
|
|
770
|
-
t0 = Math.max(t0, r); // Update t0 to the latest entry point along the line.
|
|
771
|
-
} else { // p[i] > 0
|
|
772
|
-
// Line is potentially exiting the clip region with respect to this edge.
|
|
773
|
-
// (e.g., for left edge, -dx > 0 means line goes from right to left, dx < 0).
|
|
774
|
-
// We want the smallest t1 among all exit points.
|
|
775
|
-
if (r < t0) return null; // Line exits before it has entered from another edge.
|
|
776
|
-
t1 = Math.min(t1, r); // Update t1 to the earliest exit point along the line.
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// After checking all 4 edges:
|
|
782
|
-
// If t0 > t1, the line segment is completely outside the clipping window or is degenerate.
|
|
783
|
-
if (t0 > t1) return null;
|
|
784
|
-
|
|
785
|
-
// Calculate the new clipped coordinates using parameters t0 and t1.
|
|
786
|
-
// (x1_clipped, y1_clipped) = (x1, y1) + t0 * (dx, dy)
|
|
787
|
-
// (x2_clipped, y2_clipped) = (x1, y1) + t1 * (dx, dy)
|
|
788
|
-
const clippedX1 = x1 + t0 * dx;
|
|
789
|
-
const clippedY1 = y1 + t0 * dy;
|
|
790
|
-
const clippedX2 = x1 + t1 * dx;
|
|
791
|
-
const clippedY2 = y1 + t1 * dy;
|
|
792
|
-
|
|
793
|
-
return { x1: clippedX1, y1: clippedY1, x2: clippedX2, y2: clippedY2 };
|
|
794
|
-
}
|
|
795
|
-
// Removed adjustLineForOverflow function
|
|
796
|
-
|
|
797
|
-
const pluginTrendlineLinear = {
|
|
798
|
-
id: 'chartjs-plugin-trendline',
|
|
799
|
-
|
|
800
|
-
afterDatasetsDraw: (chartInstance) => {
|
|
801
|
-
const ctx = chartInstance.ctx;
|
|
802
|
-
const { xScale, yScale } = getScales(chartInstance);
|
|
803
|
-
|
|
804
|
-
const sortedDatasets = chartInstance.data.datasets
|
|
805
|
-
.map((dataset, index) => ({ dataset, index }))
|
|
806
|
-
.filter((entry) => entry.dataset.trendlineLinear || entry.dataset.trendlineExponential)
|
|
807
|
-
.sort((a, b) => {
|
|
808
|
-
const orderA = a.dataset.order ?? 0;
|
|
809
|
-
const orderB = b.dataset.order ?? 0;
|
|
810
|
-
|
|
811
|
-
// Push 0-order datasets to the end (they draw last / on top)
|
|
812
|
-
if (orderA === 0 && orderB !== 0) return 1;
|
|
813
|
-
if (orderB === 0 && orderA !== 0) return -1;
|
|
814
|
-
|
|
815
|
-
// Otherwise, draw lower order first
|
|
816
|
-
return orderA - orderB;
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
sortedDatasets.forEach(({ dataset, index }) => {
|
|
820
|
-
const showTrendline =
|
|
821
|
-
dataset.alwaysShowTrendline ||
|
|
822
|
-
chartInstance.isDatasetVisible(index);
|
|
823
|
-
|
|
824
|
-
if (showTrendline && dataset.data.length > 1) {
|
|
825
|
-
const datasetMeta = chartInstance.getDatasetMeta(index);
|
|
826
|
-
addFitter(datasetMeta, ctx, dataset, xScale, yScale);
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
// Reset to solid line after drawing trendline
|
|
831
|
-
ctx.setLineDash([]);
|
|
832
|
-
},
|
|
833
|
-
|
|
834
|
-
beforeInit: (chartInstance) => {
|
|
835
|
-
const datasets = chartInstance.data.datasets;
|
|
836
|
-
|
|
837
|
-
datasets.forEach((dataset) => {
|
|
838
|
-
const trendlineConfig = dataset.trendlineLinear || dataset.trendlineExponential;
|
|
839
|
-
if (trendlineConfig && trendlineConfig.label) {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
chart
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
export { pluginTrendlineLinear as default };
|
|
1
|
+
/*!
|
|
2
|
+
* chartjs-plugin-trendline v3.2.3
|
|
3
|
+
* https://github.com/Makanz/chartjs-plugin-trendline
|
|
4
|
+
* (c) 2026 Marcus Alsterfjord
|
|
5
|
+
* Released under the MIT license
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* A class that fits a line to a series of points using least squares.
|
|
9
|
+
*/
|
|
10
|
+
class LineFitter {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.count = 0;
|
|
13
|
+
this.sumx = 0;
|
|
14
|
+
this.sumy = 0;
|
|
15
|
+
this.sumx2 = 0;
|
|
16
|
+
this.sumxy = 0;
|
|
17
|
+
this.minx = Number.MAX_VALUE;
|
|
18
|
+
this.maxx = Number.MIN_VALUE;
|
|
19
|
+
this._cachedSlope = null;
|
|
20
|
+
this._cachedIntercept = null;
|
|
21
|
+
this._cacheValid = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Adds a point to the line fitter.
|
|
26
|
+
* @param {number} x - The x-coordinate of the point.
|
|
27
|
+
* @param {number} y - The y-coordinate of the point.
|
|
28
|
+
*/
|
|
29
|
+
add(x, y) {
|
|
30
|
+
this.sumx += x;
|
|
31
|
+
this.sumy += y;
|
|
32
|
+
this.sumx2 += x * x;
|
|
33
|
+
this.sumxy += x * y;
|
|
34
|
+
if (x < this.minx) this.minx = x;
|
|
35
|
+
if (x > this.maxx) this.maxx = x;
|
|
36
|
+
this.count++;
|
|
37
|
+
this._cacheValid = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calculates the slope of the fitted line.
|
|
42
|
+
* @returns {number} - The slope of the line.
|
|
43
|
+
*/
|
|
44
|
+
slope() {
|
|
45
|
+
if (!this._cacheValid) {
|
|
46
|
+
this._computeCoefficients();
|
|
47
|
+
}
|
|
48
|
+
return this._cachedSlope;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Calculates the y-intercept of the fitted line.
|
|
53
|
+
* @returns {number} - The y-intercept of the line.
|
|
54
|
+
*/
|
|
55
|
+
intercept() {
|
|
56
|
+
if (!this._cacheValid) {
|
|
57
|
+
this._computeCoefficients();
|
|
58
|
+
}
|
|
59
|
+
return this._cachedIntercept;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns the fitted value (y) for a given x.
|
|
64
|
+
* @param {number} x - The x-coordinate.
|
|
65
|
+
* @returns {number} - The corresponding y-coordinate on the fitted line.
|
|
66
|
+
*/
|
|
67
|
+
f(x) {
|
|
68
|
+
return this.slope() * x + this.intercept();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Calculates the projection of the line for the future value.
|
|
73
|
+
* @returns {number} - The future value based on the fitted line.
|
|
74
|
+
*/
|
|
75
|
+
fo() {
|
|
76
|
+
return -this.intercept() / this.slope();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns the scale (variance) of the fitted line.
|
|
81
|
+
* @returns {number} - The scale of the fitted line.
|
|
82
|
+
*/
|
|
83
|
+
scale() {
|
|
84
|
+
return this.slope();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_computeCoefficients() {
|
|
88
|
+
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
89
|
+
this._cachedSlope = (this.count * this.sumxy - this.sumx * this.sumy) / denominator;
|
|
90
|
+
this._cachedIntercept = (this.sumy - this._cachedSlope * this.sumx) / this.count;
|
|
91
|
+
this._cacheValid = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A class that fits an exponential curve to a series of points using least squares.
|
|
97
|
+
* Fits y = a * e^(b*x) by transforming to ln(y) = ln(a) + b*x
|
|
98
|
+
*/
|
|
99
|
+
class ExponentialFitter {
|
|
100
|
+
constructor() {
|
|
101
|
+
this.count = 0;
|
|
102
|
+
this.sumx = 0;
|
|
103
|
+
this.sumlny = 0;
|
|
104
|
+
this.sumx2 = 0;
|
|
105
|
+
this.sumxlny = 0;
|
|
106
|
+
this.minx = Number.MAX_VALUE;
|
|
107
|
+
this.maxx = Number.MIN_VALUE;
|
|
108
|
+
this.hasValidData = true;
|
|
109
|
+
this.dataPoints = []; // Store data points for correlation calculation
|
|
110
|
+
this._cachedGrowthRate = null;
|
|
111
|
+
this._cachedCoefficient = null;
|
|
112
|
+
this._cachedCorrelation = null;
|
|
113
|
+
this._cacheValid = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Adds a point to the exponential fitter.
|
|
118
|
+
* @param {number} x - The x-coordinate of the point.
|
|
119
|
+
* @param {number} y - The y-coordinate of the point.
|
|
120
|
+
*/
|
|
121
|
+
add(x, y) {
|
|
122
|
+
if (y <= 0) {
|
|
123
|
+
this.hasValidData = false;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const lny = Math.log(y);
|
|
128
|
+
if (!isFinite(lny)) {
|
|
129
|
+
this.hasValidData = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.sumx += x;
|
|
134
|
+
this.sumlny += lny;
|
|
135
|
+
this.sumx2 += x * x;
|
|
136
|
+
this.sumxlny += x * lny;
|
|
137
|
+
if (x < this.minx) this.minx = x;
|
|
138
|
+
if (x > this.maxx) this.maxx = x;
|
|
139
|
+
this.dataPoints.push({x, y, lny}); // Store actual data points
|
|
140
|
+
this.count++;
|
|
141
|
+
this._cacheValid = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Calculates the exponential growth rate (b in y = a * e^(b*x)).
|
|
146
|
+
* @returns {number} - The exponential growth rate.
|
|
147
|
+
*/
|
|
148
|
+
growthRate() {
|
|
149
|
+
if (!this.hasValidData || this.count < 2) return 0;
|
|
150
|
+
if (!this._cacheValid) {
|
|
151
|
+
this._computeCoefficients();
|
|
152
|
+
}
|
|
153
|
+
return this._cachedGrowthRate;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Calculates the exponential coefficient (a in y = a * e^(b*x)).
|
|
158
|
+
* @returns {number} - The exponential coefficient.
|
|
159
|
+
*/
|
|
160
|
+
coefficient() {
|
|
161
|
+
if (!this.hasValidData || this.count < 2) return 1;
|
|
162
|
+
if (!this._cacheValid) {
|
|
163
|
+
this._computeCoefficients();
|
|
164
|
+
}
|
|
165
|
+
return this._cachedCoefficient;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Returns the fitted exponential value (y) for a given x.
|
|
170
|
+
* @param {number} x - The x-coordinate.
|
|
171
|
+
* @returns {number} - The corresponding y-coordinate on the fitted exponential curve.
|
|
172
|
+
*/
|
|
173
|
+
f(x) {
|
|
174
|
+
if (!this.hasValidData || this.count < 2) return 0;
|
|
175
|
+
if (!this._cacheValid) {
|
|
176
|
+
this._computeCoefficients();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check for potential overflow before calculation
|
|
180
|
+
if (Math.abs(this._cachedGrowthRate * x) > 500) return 0; // Safer limit to prevent overflow
|
|
181
|
+
|
|
182
|
+
const result = this._cachedCoefficient * Math.exp(this._cachedGrowthRate * x);
|
|
183
|
+
return isFinite(result) ? result : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Calculates the correlation coefficient (R-squared) for the exponential fit.
|
|
188
|
+
* @returns {number} - The correlation coefficient (0-1).
|
|
189
|
+
*/
|
|
190
|
+
correlation() {
|
|
191
|
+
if (!this.hasValidData || this.count < 2) return 0;
|
|
192
|
+
if (!this._cacheValid) {
|
|
193
|
+
this._computeCoefficients();
|
|
194
|
+
}
|
|
195
|
+
return this._cachedCorrelation;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns the scale (growth rate) of the fitted exponential curve.
|
|
200
|
+
* @returns {number} - The growth rate of the exponential curve.
|
|
201
|
+
*/
|
|
202
|
+
scale() {
|
|
203
|
+
return this.growthRate();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_computeCoefficients() {
|
|
207
|
+
if (!this.hasValidData || this.count < 2) {
|
|
208
|
+
this._cachedGrowthRate = 0;
|
|
209
|
+
this._cachedCoefficient = 1;
|
|
210
|
+
this._cachedCorrelation = 0;
|
|
211
|
+
this._cacheValid = true;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
216
|
+
if (Math.abs(denominator) < 1e-10) {
|
|
217
|
+
this._cachedGrowthRate = 0;
|
|
218
|
+
this._cachedCoefficient = 1;
|
|
219
|
+
this._cachedCorrelation = 0;
|
|
220
|
+
this._cacheValid = true;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this._cachedGrowthRate = (this.count * this.sumxlny - this.sumx * this.sumlny) / denominator;
|
|
225
|
+
const lnA = (this.sumlny - this._cachedGrowthRate * this.sumx) / this.count;
|
|
226
|
+
this._cachedCoefficient = Math.exp(lnA);
|
|
227
|
+
|
|
228
|
+
const meanLnY = this.sumlny / this.count;
|
|
229
|
+
let ssTotal = 0;
|
|
230
|
+
let ssRes = 0;
|
|
231
|
+
|
|
232
|
+
for (const point of this.dataPoints) {
|
|
233
|
+
const predictedLnY = lnA + this._cachedGrowthRate * point.x;
|
|
234
|
+
ssTotal += Math.pow(point.lny - meanLnY, 2);
|
|
235
|
+
ssRes += Math.pow(point.lny - predictedLnY, 2);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._cachedCorrelation = ssTotal === 0 ? 1 : Math.max(0, 1 - (ssRes / ssTotal));
|
|
239
|
+
this._cacheValid = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Retrieves the x and y scales from the chart instance.
|
|
245
|
+
* @param {Chart} chartInstance - The chart instance.
|
|
246
|
+
* @returns {Object} - The xScale and yScale of the chart.
|
|
247
|
+
*/
|
|
248
|
+
const getScales = (chartInstance) => {
|
|
249
|
+
let xScale, yScale;
|
|
250
|
+
for (const scale of Object.values(chartInstance.scales)) {
|
|
251
|
+
if (scale.isHorizontal()) xScale = scale;
|
|
252
|
+
else yScale = scale;
|
|
253
|
+
if (xScale && yScale) break;
|
|
254
|
+
}
|
|
255
|
+
return { xScale, yScale };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Sets the line style (dashed, dotted, solid) for the canvas context.
|
|
260
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
261
|
+
* @param {string} lineStyle - The style of the line ('dotted', 'dashed', 'solid', etc.).
|
|
262
|
+
*/
|
|
263
|
+
const setLineStyle = (ctx, lineStyle) => {
|
|
264
|
+
switch (lineStyle) {
|
|
265
|
+
case 'dotted':
|
|
266
|
+
ctx.setLineDash([2, 2]);
|
|
267
|
+
break;
|
|
268
|
+
case 'dashed':
|
|
269
|
+
ctx.setLineDash([8, 3]);
|
|
270
|
+
break;
|
|
271
|
+
case 'dashdot':
|
|
272
|
+
ctx.setLineDash([8, 3, 2, 3]);
|
|
273
|
+
break;
|
|
274
|
+
case 'solid':
|
|
275
|
+
default:
|
|
276
|
+
ctx.setLineDash([]);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Draws the trendline on the canvas context.
|
|
283
|
+
* @param {Object} params - The trendline parameters.
|
|
284
|
+
* @param {CanvasRenderingContext2D} params.ctx - The canvas rendering context.
|
|
285
|
+
* @param {number} params.x1 - Starting x-coordinate of the trendline.
|
|
286
|
+
* @param {number} params.y1 - Starting y-coordinate of the trendline.
|
|
287
|
+
* @param {number} params.x2 - Ending x-coordinate of the trendline.
|
|
288
|
+
* @param {number} params.y2 - Ending y-coordinate of the trendline.
|
|
289
|
+
* @param {string} params.colorMin - The starting color of the trendline gradient.
|
|
290
|
+
* @param {string} params.colorMax - The ending color of the trendline gradient.
|
|
291
|
+
*/
|
|
292
|
+
const drawTrendline = ({ ctx, x1, y1, x2, y2, colorMin, colorMax }) => {
|
|
293
|
+
// Ensure all values are finite numbers
|
|
294
|
+
if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
|
|
295
|
+
console.warn(
|
|
296
|
+
'Cannot draw trendline: coordinates contain non-finite values',
|
|
297
|
+
{ x1, y1, x2, y2 }
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
ctx.beginPath();
|
|
303
|
+
ctx.moveTo(x1, y1);
|
|
304
|
+
ctx.lineTo(x2, y2);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// Additional validation for degenerate gradients
|
|
308
|
+
const dx = x2 - x1;
|
|
309
|
+
const dy = y2 - y1;
|
|
310
|
+
const gradientLength = Math.sqrt(dx * dx + dy * dy);
|
|
311
|
+
|
|
312
|
+
// If the gradient vector is too small, createLinearGradient may fail
|
|
313
|
+
if (gradientLength < 0.01) {
|
|
314
|
+
console.warn('Gradient vector too small, using solid color:', { x1, y1, x2, y2, length: gradientLength });
|
|
315
|
+
ctx.strokeStyle = colorMin;
|
|
316
|
+
} else {
|
|
317
|
+
let gradient = ctx.createLinearGradient(x1, y1, x2, y2);
|
|
318
|
+
gradient.addColorStop(0, colorMin);
|
|
319
|
+
gradient.addColorStop(1, colorMax);
|
|
320
|
+
ctx.strokeStyle = gradient;
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
// Fallback to solid color if gradient creation fails
|
|
324
|
+
console.warn('Gradient creation failed, using solid color:', e);
|
|
325
|
+
ctx.strokeStyle = colorMin;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ctx.stroke();
|
|
329
|
+
ctx.closePath();
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Fills the area below the trendline with the specified color.
|
|
334
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
335
|
+
* @param {number} x1 - Starting x-coordinate of the trendline.
|
|
336
|
+
* @param {number} y1 - Starting y-coordinate of the trendline.
|
|
337
|
+
* @param {number} x2 - Ending x-coordinate of the trendline.
|
|
338
|
+
* @param {number} y2 - Ending y-coordinate of the trendline.
|
|
339
|
+
* @param {number} drawBottom - The bottom boundary of the chart.
|
|
340
|
+
* @param {string} fillColor - The color to fill below the trendline.
|
|
341
|
+
*/
|
|
342
|
+
const fillBelowTrendline = (ctx, x1, y1, x2, y2, drawBottom, fillColor) => {
|
|
343
|
+
// Ensure all values are finite numbers
|
|
344
|
+
if (
|
|
345
|
+
!isFinite(x1) ||
|
|
346
|
+
!isFinite(y1) ||
|
|
347
|
+
!isFinite(x2) ||
|
|
348
|
+
!isFinite(y2) ||
|
|
349
|
+
!isFinite(drawBottom)
|
|
350
|
+
) {
|
|
351
|
+
console.warn(
|
|
352
|
+
'Cannot fill below trendline: coordinates contain non-finite values',
|
|
353
|
+
{ x1, y1, x2, y2, drawBottom }
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
ctx.beginPath();
|
|
359
|
+
ctx.moveTo(x1, y1);
|
|
360
|
+
ctx.lineTo(x2, y2);
|
|
361
|
+
ctx.lineTo(x2, drawBottom);
|
|
362
|
+
ctx.lineTo(x1, drawBottom);
|
|
363
|
+
ctx.lineTo(x1, y1);
|
|
364
|
+
ctx.closePath();
|
|
365
|
+
|
|
366
|
+
ctx.fillStyle = fillColor;
|
|
367
|
+
ctx.fill();
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Adds a label to the trendline at the calculated angle.
|
|
372
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
373
|
+
* @param {string} label - The label text to add.
|
|
374
|
+
* @param {number} x1 - The starting x-coordinate of the trendline.
|
|
375
|
+
* @param {number} y1 - The starting y-coordinate of the trendline.
|
|
376
|
+
* @param {number} x2 - The ending x-coordinate of the trendline.
|
|
377
|
+
* @param {number} y2 - The ending y-coordinate of the trendline.
|
|
378
|
+
* @param {number} angle - The angle (in radians) of the trendline.
|
|
379
|
+
* @param {string} labelColor - The color of the label text.
|
|
380
|
+
* @param {string} family - The font family for the label text.
|
|
381
|
+
* @param {number} size - The font size for the label text.
|
|
382
|
+
* @param {number} offset - The offset of the label from the trendline
|
|
383
|
+
*/
|
|
384
|
+
const addTrendlineLabel = (
|
|
385
|
+
ctx,
|
|
386
|
+
label,
|
|
387
|
+
x1,
|
|
388
|
+
y1,
|
|
389
|
+
x2,
|
|
390
|
+
y2,
|
|
391
|
+
angle,
|
|
392
|
+
labelColor,
|
|
393
|
+
family,
|
|
394
|
+
size,
|
|
395
|
+
offset
|
|
396
|
+
) => {
|
|
397
|
+
// Set the label font and color
|
|
398
|
+
ctx.font = `${size}px ${family}`;
|
|
399
|
+
ctx.fillStyle = labelColor;
|
|
400
|
+
|
|
401
|
+
// Label width
|
|
402
|
+
const labelWidth = ctx.measureText(label).width;
|
|
403
|
+
|
|
404
|
+
// Calculate the center of the trendline
|
|
405
|
+
const labelX = (x1 + x2) / 2;
|
|
406
|
+
const labelY = (y1 + y2) / 2;
|
|
407
|
+
|
|
408
|
+
// Save the current state of the canvas
|
|
409
|
+
ctx.save();
|
|
410
|
+
|
|
411
|
+
// Translate to the label position
|
|
412
|
+
ctx.translate(labelX, labelY);
|
|
413
|
+
|
|
414
|
+
// Rotate the context to align with the trendline
|
|
415
|
+
ctx.rotate(angle);
|
|
416
|
+
|
|
417
|
+
// Adjust for the length of the label and rotation
|
|
418
|
+
const adjustedX = -labelWidth / 2; // Center the label horizontally
|
|
419
|
+
const adjustedY = offset; // Adjust Y to compensate for the height
|
|
420
|
+
|
|
421
|
+
// Draw the label
|
|
422
|
+
ctx.fillText(label, adjustedX, adjustedY);
|
|
423
|
+
|
|
424
|
+
// Restore the canvas state
|
|
425
|
+
ctx.restore();
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Adds a trendline (fitter) to the dataset on the chart and optionally labels it with trend value.
|
|
430
|
+
* @param {Object} datasetMeta - Metadata about the dataset.
|
|
431
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context.
|
|
432
|
+
* @param {Object} dataset - The dataset configuration from the chart.
|
|
433
|
+
* @param {Scale} xScale - The x-axis scale object.
|
|
434
|
+
* @param {Scale} yScale - The y-axis scale object.
|
|
435
|
+
*/
|
|
436
|
+
const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
|
|
437
|
+
const yAxisID = dataset.yAxisID || 'y'; // Default to 'y' if no yAxisID is specified
|
|
438
|
+
const yScaleToUse = datasetMeta.controller.chart.scales[yAxisID] || yScale;
|
|
439
|
+
|
|
440
|
+
// Determine if we're using exponential or linear trendline
|
|
441
|
+
const isExponential = !!dataset.trendlineExponential;
|
|
442
|
+
const trendlineConfig = dataset.trendlineExponential || dataset.trendlineLinear || {};
|
|
443
|
+
|
|
444
|
+
const defaultColor = dataset.borderColor || 'rgba(169,169,169, .6)';
|
|
445
|
+
const {
|
|
446
|
+
colorMin = defaultColor,
|
|
447
|
+
colorMax = defaultColor,
|
|
448
|
+
width: lineWidth = dataset.borderWidth || 3,
|
|
449
|
+
lineStyle = 'solid',
|
|
450
|
+
fillColor = false,
|
|
451
|
+
// trendoffset is now handled separately
|
|
452
|
+
} = trendlineConfig;
|
|
453
|
+
let trendoffset = trendlineConfig.trendoffset || 0;
|
|
454
|
+
|
|
455
|
+
const {
|
|
456
|
+
color = defaultColor,
|
|
457
|
+
text = isExponential ? 'Exponential Trendline' : 'Trendline',
|
|
458
|
+
display = true,
|
|
459
|
+
displayValue = true,
|
|
460
|
+
offset = 10,
|
|
461
|
+
percentage = false,
|
|
462
|
+
} = (trendlineConfig && trendlineConfig.label) || {};
|
|
463
|
+
|
|
464
|
+
const {
|
|
465
|
+
family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
|
|
466
|
+
size = 12,
|
|
467
|
+
} = (trendlineConfig && trendlineConfig.label && trendlineConfig.label.font) || {};
|
|
468
|
+
|
|
469
|
+
const chartOptions = datasetMeta.controller.chart.options;
|
|
470
|
+
const parsingOptions =
|
|
471
|
+
typeof chartOptions.parsing === 'object'
|
|
472
|
+
? chartOptions.parsing
|
|
473
|
+
: undefined;
|
|
474
|
+
const xAxisKey =
|
|
475
|
+
trendlineConfig?.xAxisKey || parsingOptions?.xAxisKey || 'x';
|
|
476
|
+
const yAxisKey =
|
|
477
|
+
trendlineConfig?.yAxisKey || parsingOptions?.yAxisKey || 'y';
|
|
478
|
+
|
|
479
|
+
let fitter = isExponential ? new ExponentialFitter() : new LineFitter();
|
|
480
|
+
|
|
481
|
+
// --- Data Point Collection and Validation for LineFitter ---
|
|
482
|
+
|
|
483
|
+
// Sanitize trendoffset: if its absolute value is too large, reset to 0.
|
|
484
|
+
// This prevents errors if offset is out of bounds of the dataset length.
|
|
485
|
+
if (Math.abs(trendoffset) >= dataset.data.length) trendoffset = 0;
|
|
486
|
+
|
|
487
|
+
// Determine the actual starting index for data processing if a positive trendoffset is applied.
|
|
488
|
+
// This skips initial data points and finds the first non-null data point thereafter.
|
|
489
|
+
// `effectiveFirstIndex` is used to determine the data type ('xy' or array) and to skip initial points for positive offset.
|
|
490
|
+
let effectiveFirstIndex = 0;
|
|
491
|
+
if (trendoffset > 0) {
|
|
492
|
+
// Start searching for a non-null point from the offset.
|
|
493
|
+
const firstNonNullAfterOffset = dataset.data.slice(trendoffset).findIndex((d) => d !== undefined && d !== null);
|
|
494
|
+
if (firstNonNullAfterOffset !== -1) {
|
|
495
|
+
effectiveFirstIndex = trendoffset + firstNonNullAfterOffset;
|
|
496
|
+
} else {
|
|
497
|
+
// All points after the offset are null or undefined, so effectively no data for trendline.
|
|
498
|
+
effectiveFirstIndex = dataset.data.length;
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
// For zero or negative offset, the initial search for 'xy' type detection starts from the beginning of the dataset.
|
|
502
|
+
// The actual exclusion of points for negative offset (from the end) is handled per-point within the loop.
|
|
503
|
+
const firstNonNull = dataset.data.findIndex((d) => d !== undefined && d !== null);
|
|
504
|
+
if (firstNonNull !== -1) {
|
|
505
|
+
effectiveFirstIndex = firstNonNull;
|
|
506
|
+
} else {
|
|
507
|
+
// All data in the dataset is null or undefined.
|
|
508
|
+
effectiveFirstIndex = dataset.data.length;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Determine data structure type (object {x,y} or array of numbers) based on the first valid data point.
|
|
513
|
+
// This informs how `xAxisKey` and `yAxisKey` are used or if `index` is used for x-values.
|
|
514
|
+
let xy = effectiveFirstIndex < dataset.data.length && typeof dataset.data[effectiveFirstIndex] === 'object';
|
|
515
|
+
|
|
516
|
+
// Iterate over dataset to collect points for the LineFitter.
|
|
517
|
+
dataset.data.forEach((data, index) => {
|
|
518
|
+
// Skip any data point that is null or undefined directly. This is a general guard.
|
|
519
|
+
if (data == null) return;
|
|
520
|
+
|
|
521
|
+
// Apply trendoffset logic for including/excluding points:
|
|
522
|
+
// 1. Positive offset: Skip data points if their index is before the `effectiveFirstIndex`.
|
|
523
|
+
// `effectiveFirstIndex` already accounts for the offset and initial nulls.
|
|
524
|
+
if (trendoffset > 0 && index < effectiveFirstIndex) return;
|
|
525
|
+
// 2. Negative offset: Skip data points if their index is at or after the calculated end point.
|
|
526
|
+
// `dataset.data.length + trendoffset` marks the first index of the points to be excluded from the end.
|
|
527
|
+
// For example, if length is 10 and offset is -2, points from index 8 onwards are skipped.
|
|
528
|
+
if (trendoffset < 0 && index >= dataset.data.length + trendoffset) return;
|
|
529
|
+
|
|
530
|
+
// Process data based on scale type and data structure.
|
|
531
|
+
if (['time', 'timeseries'].includes(xScale.options.type) && xy) {
|
|
532
|
+
// For time-based scales with object data, convert x to a numerical timestamp; ensure y is a valid number.
|
|
533
|
+
let x = data[xAxisKey] != null ? data[xAxisKey] : data.t; // `data.t` is a Chart.js internal fallback for time data.
|
|
534
|
+
const yValue = data[yAxisKey];
|
|
535
|
+
|
|
536
|
+
// Both x and y must be valid for the point to be included.
|
|
537
|
+
if (x != null && x !== undefined && yValue != null && !isNaN(yValue)) {
|
|
538
|
+
fitter.add(new Date(x).getTime(), yValue);
|
|
539
|
+
}
|
|
540
|
+
// If x or yValue is invalid, the point is skipped.
|
|
541
|
+
} else if (xy) { // Data is identified as array of objects {x,y}.
|
|
542
|
+
const xVal = data[xAxisKey];
|
|
543
|
+
const yVal = data[yAxisKey];
|
|
544
|
+
|
|
545
|
+
const xIsValid = xVal != null && !isNaN(xVal);
|
|
546
|
+
const yIsValid = yVal != null && !isNaN(yVal);
|
|
547
|
+
|
|
548
|
+
// Both xVal and yVal must be valid numbers to include the point.
|
|
549
|
+
if (xIsValid && yIsValid) {
|
|
550
|
+
fitter.add(xVal, yVal);
|
|
551
|
+
}
|
|
552
|
+
// If either xVal or yVal is invalid, the point is skipped. No fallback to using index.
|
|
553
|
+
} else if (['time', 'timeseries'].includes(xScale.options.type) && !xy) {
|
|
554
|
+
// For time-based scales with array of numbers, get the x-value from the chart labels
|
|
555
|
+
const chartLabels = datasetMeta.controller.chart.data.labels;
|
|
556
|
+
if (chartLabels && chartLabels[index] && data != null && !isNaN(data)) {
|
|
557
|
+
const timeValue = new Date(chartLabels[index]).getTime();
|
|
558
|
+
if (!isNaN(timeValue)) {
|
|
559
|
+
fitter.add(timeValue, data);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
// Data is an array of numbers (or other non-object types).
|
|
564
|
+
// The 'data' variable itself is the y-value, and 'index' is the x-value.
|
|
565
|
+
// We still need to check for null/NaN here because 'data' (the y-value) could be null/NaN
|
|
566
|
+
// even if the entry 'data' (the point/container) wasn't null in the initial check.
|
|
567
|
+
// This applies if dataset.data = [1, 2, null, 4].
|
|
568
|
+
if (data != null && !isNaN(data)) {
|
|
569
|
+
fitter.add(index, data);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// --- Trendline Coordinate Calculation ---
|
|
575
|
+
// Ensure there are enough points to form a trendline.
|
|
576
|
+
if (fitter.count < 2) {
|
|
577
|
+
return; // Not enough data points to calculate a trendline.
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// These variables will hold the pixel coordinates for drawing the trendline.
|
|
581
|
+
let x1_px, y1_px, x2_px, y2_px;
|
|
582
|
+
|
|
583
|
+
const chartArea = datasetMeta.controller.chart.chartArea; // Defines the drawable area in pixels.
|
|
584
|
+
|
|
585
|
+
// Determine trendline start/end points based on the 'projection' option.
|
|
586
|
+
if (trendlineConfig.projection) {
|
|
587
|
+
let points = [];
|
|
588
|
+
|
|
589
|
+
if (isExponential) {
|
|
590
|
+
// For exponential curves, we generate points across the x-axis range
|
|
591
|
+
const val_x_left = xScale.getValueForPixel(chartArea.left);
|
|
592
|
+
const y_at_left = fitter.f(val_x_left);
|
|
593
|
+
points.push({ x: val_x_left, y: y_at_left });
|
|
594
|
+
|
|
595
|
+
const val_x_right = xScale.getValueForPixel(chartArea.right);
|
|
596
|
+
const y_at_right = fitter.f(val_x_right);
|
|
597
|
+
points.push({ x: val_x_right, y: y_at_right });
|
|
598
|
+
} else {
|
|
599
|
+
// Linear projection logic (existing code)
|
|
600
|
+
const slope = fitter.slope();
|
|
601
|
+
const intercept = fitter.intercept();
|
|
602
|
+
|
|
603
|
+
if (Math.abs(slope) > 1e-6) {
|
|
604
|
+
const val_y_top = yScaleToUse.getValueForPixel(chartArea.top);
|
|
605
|
+
const x_at_top = (val_y_top - intercept) / slope;
|
|
606
|
+
points.push({ x: x_at_top, y: val_y_top });
|
|
607
|
+
|
|
608
|
+
const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom);
|
|
609
|
+
const x_at_bottom = (val_y_bottom - intercept) / slope;
|
|
610
|
+
points.push({ x: x_at_bottom, y: val_y_bottom });
|
|
611
|
+
} else {
|
|
612
|
+
points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept});
|
|
613
|
+
points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const val_x_left = xScale.getValueForPixel(chartArea.left);
|
|
617
|
+
const y_at_left = fitter.f(val_x_left);
|
|
618
|
+
points.push({ x: val_x_left, y: y_at_left });
|
|
619
|
+
|
|
620
|
+
const val_x_right = xScale.getValueForPixel(chartArea.right);
|
|
621
|
+
const y_at_right = fitter.f(val_x_right);
|
|
622
|
+
points.push({ x: val_x_right, y: y_at_right });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const chartMinX = xScale.getValueForPixel(chartArea.left);
|
|
626
|
+
const chartMaxX = xScale.getValueForPixel(chartArea.right);
|
|
627
|
+
|
|
628
|
+
const yValsFromPixels = [yScaleToUse.getValueForPixel(chartArea.top), yScaleToUse.getValueForPixel(chartArea.bottom)];
|
|
629
|
+
const finiteYVals = yValsFromPixels.filter(y => isFinite(y));
|
|
630
|
+
// Ensure actualChartMinY and actualChartMaxY are correctly ordered for the filter
|
|
631
|
+
const actualChartMinY = finiteYVals.length > 0 ? Math.min(...finiteYVals) : -Infinity;
|
|
632
|
+
const actualChartMaxY = finiteYVals.length > 0 ? Math.max(...finiteYVals) : Infinity;
|
|
633
|
+
|
|
634
|
+
let validPoints = points.filter(p =>
|
|
635
|
+
isFinite(p.x) && isFinite(p.y) &&
|
|
636
|
+
p.x >= chartMinX && p.x <= chartMaxX && p.y >= actualChartMinY && p.y <= actualChartMaxY
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
validPoints = validPoints.filter((point, index, self) =>
|
|
640
|
+
index === self.findIndex((t) => (
|
|
641
|
+
Math.abs(t.x - point.x) < 1e-4 && Math.abs(t.y - point.y) < 1e-4
|
|
642
|
+
))
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (validPoints.length >= 2) {
|
|
646
|
+
validPoints.sort((a,b) => a.x - b.x || a.y - b.y);
|
|
647
|
+
|
|
648
|
+
x1_px = xScale.getPixelForValue(validPoints[0].x);
|
|
649
|
+
y1_px = yScaleToUse.getPixelForValue(validPoints[0].y);
|
|
650
|
+
x2_px = xScale.getPixelForValue(validPoints[validPoints.length - 1].x);
|
|
651
|
+
y2_px = yScaleToUse.getPixelForValue(validPoints[validPoints.length - 1].y);
|
|
652
|
+
} else {
|
|
653
|
+
x1_px = NaN; y1_px = NaN; x2_px = NaN; y2_px = NaN;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
} else {
|
|
657
|
+
const y_at_minx = fitter.f(fitter.minx);
|
|
658
|
+
const y_at_maxx = fitter.f(fitter.maxx);
|
|
659
|
+
|
|
660
|
+
x1_px = xScale.getPixelForValue(fitter.minx);
|
|
661
|
+
y1_px = yScaleToUse.getPixelForValue(y_at_minx);
|
|
662
|
+
x2_px = xScale.getPixelForValue(fitter.maxx);
|
|
663
|
+
y2_px = yScaleToUse.getPixelForValue(y_at_maxx);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// --- Line Clipping and Drawing ---
|
|
667
|
+
let clippedCoords = null;
|
|
668
|
+
if (isFinite(x1_px) && isFinite(y1_px) && isFinite(x2_px) && isFinite(y2_px)) {
|
|
669
|
+
clippedCoords = liangBarskyClip(x1_px, y1_px, x2_px, y2_px, chartArea);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (clippedCoords) {
|
|
673
|
+
x1_px = clippedCoords.x1;
|
|
674
|
+
y1_px = clippedCoords.y1;
|
|
675
|
+
x2_px = clippedCoords.x2;
|
|
676
|
+
y2_px = clippedCoords.y2;
|
|
677
|
+
|
|
678
|
+
if (Math.abs(x1_px - x2_px) < 0.5 && Math.abs(y1_px - y2_px) < 0.5) ; else {
|
|
679
|
+
ctx.lineWidth = lineWidth;
|
|
680
|
+
setLineStyle(ctx, lineStyle);
|
|
681
|
+
drawTrendline({ ctx, x1: x1_px, y1: y1_px, x2: x2_px, y2: y2_px, colorMin, colorMax });
|
|
682
|
+
|
|
683
|
+
if (fillColor) {
|
|
684
|
+
fillBelowTrendline(ctx, x1_px, y1_px, x2_px, y2_px, chartArea.bottom, fillColor);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const angle = Math.atan2(y2_px - y1_px, x2_px - x1_px);
|
|
688
|
+
|
|
689
|
+
if (trendlineConfig.label && display !== false) {
|
|
690
|
+
let trendText = text;
|
|
691
|
+
if (displayValue) {
|
|
692
|
+
if (isExponential) {
|
|
693
|
+
const coefficient = fitter.coefficient();
|
|
694
|
+
const growthRate = fitter.growthRate();
|
|
695
|
+
trendText = `${text} (a=${coefficient.toFixed(2)}, b=${growthRate.toFixed(2)})`;
|
|
696
|
+
} else {
|
|
697
|
+
const displaySlope = fitter.slope();
|
|
698
|
+
trendText = `${text} (Slope: ${
|
|
699
|
+
percentage
|
|
700
|
+
? (displaySlope * 100).toFixed(2) + '%'
|
|
701
|
+
: displaySlope.toFixed(2)
|
|
702
|
+
})`;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
addTrendlineLabel(
|
|
706
|
+
ctx,
|
|
707
|
+
trendText,
|
|
708
|
+
x1_px,
|
|
709
|
+
y1_px,
|
|
710
|
+
x2_px,
|
|
711
|
+
y2_px,
|
|
712
|
+
angle,
|
|
713
|
+
color,
|
|
714
|
+
family,
|
|
715
|
+
size,
|
|
716
|
+
offset
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Clips a line segment to a rectangular clipping window using the Liang-Barsky algorithm.
|
|
725
|
+
* This algorithm is efficient for 2D line clipping against an axis-aligned rectangle.
|
|
726
|
+
* It determines the portion of the line segment (x1,y1)-(x2,y2) that is visible within
|
|
727
|
+
* the rectangle defined by chartArea {left, right, top, bottom}.
|
|
728
|
+
* @param {number} x1 - Pixel coordinate for the start of the line (x-axis).
|
|
729
|
+
* @param {number} y1 - Pixel coordinate for the start of the line (y-axis).
|
|
730
|
+
* @param {number} x2 - Pixel coordinate for the end of the line (x-axis).
|
|
731
|
+
* @param {number} y2 - Pixel coordinate for the end of the line (y-axis).
|
|
732
|
+
* @param {Object} chartArea - The chart area with { left, right, top, bottom } pixel boundaries.
|
|
733
|
+
* @returns {Object|null} An object with { x1, y1, x2, y2 } of the clipped line,
|
|
734
|
+
* or null if the line is entirely outside the window or clipped to effectively a point.
|
|
735
|
+
*/
|
|
736
|
+
function liangBarskyClip(x1, y1, x2, y2, chartArea) {
|
|
737
|
+
let dx = x2 - x1; // Change in x
|
|
738
|
+
let dy = y2 - y1; // Change in y
|
|
739
|
+
let t0 = 0.0; // Parameter for the start of the clipped line segment (initially at x1, y1).
|
|
740
|
+
// Represents the proportion along the line from (x1,y1) to (x2,y2).
|
|
741
|
+
let t1 = 1.0; // Parameter for the end of the clipped line segment (initially at x2, y2).
|
|
742
|
+
|
|
743
|
+
// p and q arrays are used in the Liang-Barsky algorithm conditions.
|
|
744
|
+
// For each of the 4 clip edges (left, right, top, bottom):
|
|
745
|
+
// p[k] * t >= q[k]
|
|
746
|
+
// p values: -dx (left), dx (right), -dy (top), dy (bottom)
|
|
747
|
+
// q values: x1 - x_min (left), x_max - x1 (right), y1 - y_min (top), y_max - y1 (bottom)
|
|
748
|
+
// Note: Canvas y-coordinates increase downwards, so chartArea.top < chartArea.bottom.
|
|
749
|
+
const p = [-dx, dx, -dy, dy];
|
|
750
|
+
const q = [
|
|
751
|
+
x1 - chartArea.left, // q[0] for left edge check
|
|
752
|
+
chartArea.right - x1, // q[1] for right edge check
|
|
753
|
+
y1 - chartArea.top, // q[2] for top edge check
|
|
754
|
+
chartArea.bottom - y1, // q[3] for bottom edge check
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
for (let i = 0; i < 4; i++) { // Iterate through the 4 clip edges (left, right, top, bottom).
|
|
758
|
+
if (p[i] === 0) { // Line is parallel to the i-th clipping edge.
|
|
759
|
+
if (q[i] < 0) { // Line is outside this parallel edge (e.g., for left edge, x1 < chartArea.left).
|
|
760
|
+
return null; // Line is completely outside, so reject.
|
|
761
|
+
}
|
|
762
|
+
// If q[i] >= 0, line is inside or on the parallel edge, so this edge doesn't clip it. Continue.
|
|
763
|
+
} else {
|
|
764
|
+
const r = q[i] / p[i]; // Parameter t where the line intersects this edge's infinite line.
|
|
765
|
+
if (p[i] < 0) {
|
|
766
|
+
// Line is potentially entering the clip region with respect to this edge.
|
|
767
|
+
// (e.g., for left edge, -dx < 0 means line goes from left to right, dx > 0).
|
|
768
|
+
// We want the largest t0 among all entry points.
|
|
769
|
+
if (r > t1) return null; // Line enters after it has already exited from another edge.
|
|
770
|
+
t0 = Math.max(t0, r); // Update t0 to the latest entry point along the line.
|
|
771
|
+
} else { // p[i] > 0
|
|
772
|
+
// Line is potentially exiting the clip region with respect to this edge.
|
|
773
|
+
// (e.g., for left edge, -dx > 0 means line goes from right to left, dx < 0).
|
|
774
|
+
// We want the smallest t1 among all exit points.
|
|
775
|
+
if (r < t0) return null; // Line exits before it has entered from another edge.
|
|
776
|
+
t1 = Math.min(t1, r); // Update t1 to the earliest exit point along the line.
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// After checking all 4 edges:
|
|
782
|
+
// If t0 > t1, the line segment is completely outside the clipping window or is degenerate.
|
|
783
|
+
if (t0 > t1) return null;
|
|
784
|
+
|
|
785
|
+
// Calculate the new clipped coordinates using parameters t0 and t1.
|
|
786
|
+
// (x1_clipped, y1_clipped) = (x1, y1) + t0 * (dx, dy)
|
|
787
|
+
// (x2_clipped, y2_clipped) = (x1, y1) + t1 * (dx, dy)
|
|
788
|
+
const clippedX1 = x1 + t0 * dx;
|
|
789
|
+
const clippedY1 = y1 + t0 * dy;
|
|
790
|
+
const clippedX2 = x1 + t1 * dx;
|
|
791
|
+
const clippedY2 = y1 + t1 * dy;
|
|
792
|
+
|
|
793
|
+
return { x1: clippedX1, y1: clippedY1, x2: clippedX2, y2: clippedY2 };
|
|
794
|
+
}
|
|
795
|
+
// Removed adjustLineForOverflow function
|
|
796
|
+
|
|
797
|
+
const pluginTrendlineLinear = {
|
|
798
|
+
id: 'chartjs-plugin-trendline',
|
|
799
|
+
|
|
800
|
+
afterDatasetsDraw: (chartInstance) => {
|
|
801
|
+
const ctx = chartInstance.ctx;
|
|
802
|
+
const { xScale, yScale } = getScales(chartInstance);
|
|
803
|
+
|
|
804
|
+
const sortedDatasets = chartInstance.data.datasets
|
|
805
|
+
.map((dataset, index) => ({ dataset, index }))
|
|
806
|
+
.filter((entry) => entry.dataset.trendlineLinear || entry.dataset.trendlineExponential)
|
|
807
|
+
.sort((a, b) => {
|
|
808
|
+
const orderA = a.dataset.order ?? 0;
|
|
809
|
+
const orderB = b.dataset.order ?? 0;
|
|
810
|
+
|
|
811
|
+
// Push 0-order datasets to the end (they draw last / on top)
|
|
812
|
+
if (orderA === 0 && orderB !== 0) return 1;
|
|
813
|
+
if (orderB === 0 && orderA !== 0) return -1;
|
|
814
|
+
|
|
815
|
+
// Otherwise, draw lower order first
|
|
816
|
+
return orderA - orderB;
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
sortedDatasets.forEach(({ dataset, index }) => {
|
|
820
|
+
const showTrendline =
|
|
821
|
+
dataset.alwaysShowTrendline ||
|
|
822
|
+
chartInstance.isDatasetVisible(index);
|
|
823
|
+
|
|
824
|
+
if (showTrendline && dataset.data.length > 1) {
|
|
825
|
+
const datasetMeta = chartInstance.getDatasetMeta(index);
|
|
826
|
+
addFitter(datasetMeta, ctx, dataset, xScale, yScale);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Reset to solid line after drawing trendline
|
|
831
|
+
ctx.setLineDash([]);
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
beforeInit: (chartInstance) => {
|
|
835
|
+
const datasets = chartInstance.data.datasets;
|
|
836
|
+
|
|
837
|
+
datasets.forEach((dataset) => {
|
|
838
|
+
const trendlineConfig = dataset.trendlineLinear || dataset.trendlineExponential;
|
|
839
|
+
if (trendlineConfig && (trendlineConfig.label || trendlineConfig.legend)) {
|
|
840
|
+
// Access chartInstance to update legend labels
|
|
841
|
+
const originalGenerateLabels =
|
|
842
|
+
chartInstance.legend.options.labels.generateLabels;
|
|
843
|
+
|
|
844
|
+
chartInstance.legend.options.labels.generateLabels = function (
|
|
845
|
+
chart
|
|
846
|
+
) {
|
|
847
|
+
const defaultLabels = originalGenerateLabels(chart);
|
|
848
|
+
|
|
849
|
+
const legendConfig = trendlineConfig.legend;
|
|
850
|
+
|
|
851
|
+
// Display the legend if it's populated and not set to hidden
|
|
852
|
+
if (legendConfig && legendConfig.display !== false) {
|
|
853
|
+
defaultLabels.push({
|
|
854
|
+
text: legendConfig.text || dataset.label || 'Trendline',
|
|
855
|
+
strokeStyle:
|
|
856
|
+
legendConfig.strokeStyle ||
|
|
857
|
+
legendConfig.color ||
|
|
858
|
+
dataset.borderColor ||
|
|
859
|
+
'rgba(169,169,169, .6)',
|
|
860
|
+
fillStyle: legendConfig.fillStyle || 'transparent',
|
|
861
|
+
lineCap: legendConfig.lineCap || 'butt',
|
|
862
|
+
lineDash: legendConfig.lineDash || [],
|
|
863
|
+
lineWidth: legendConfig.lineWidth ?? legendConfig.width ?? 1,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
return defaultLabels;
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// If we're in the browser and have access to the global Chart obj, register plugin automatically
|
|
874
|
+
if (typeof window !== 'undefined' && window.Chart) {
|
|
875
|
+
if (window.Chart.hasOwnProperty('register')) {
|
|
876
|
+
window.Chart.register(pluginTrendlineLinear);
|
|
877
|
+
} else {
|
|
878
|
+
window.Chart.plugins.register(pluginTrendlineLinear);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export { pluginTrendlineLinear as default };
|