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