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,789 +1,789 @@
1
- import { addFitter } from './trendline.js';
2
- import 'jest-canvas-mock';
3
-
4
- import { LineFitter } from '../utils/lineFitter.js';
5
- import { ExponentialFitter } from '../utils/exponentialFitter.js';
6
- import * as drawingUtils from '../utils/drawing.js';
7
- import * as labelUtils from './label.js';
8
-
9
- jest.mock('../utils/lineFitter');
10
- jest.mock('../utils/exponentialFitter');
11
- jest.mock('../utils/drawing', () => ({
12
- drawTrendline: jest.fn(),
13
- fillBelowTrendline: jest.fn(),
14
- setLineStyle: jest.fn(),
15
- }));
16
- jest.mock('./label', () => ({ addTrendlineLabel: jest.fn() }));
17
-
18
- describe('addFitter', () => {
19
- let mockCtx;
20
- let mockDatasetMeta;
21
- let mockDataset;
22
- let mockXScale;
23
- let mockYScale;
24
- let mockLineFitterInstance;
25
-
26
- beforeEach(() => {
27
- jest.clearAllMocks();
28
- mockLineFitterInstance = {
29
- add: jest.fn(),
30
- f: jest.fn(x => x * 2 + 1),
31
- slope: jest.fn(() => 2),
32
- intercept: jest.fn(() => 1),
33
- fo: jest.fn(() => 50),
34
- scale: jest.fn(() => 1),
35
- minx: undefined,
36
- maxx: undefined,
37
- count: 0,
38
- sumx: 0, sumy: 0, sumx2: 0, sumxy: 0,
39
- };
40
- LineFitter.mockImplementation(() => mockLineFitterInstance);
41
-
42
- mockCtx = {
43
- save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(),
44
- measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '',
45
- strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(),
46
- lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(),
47
- };
48
-
49
- mockDatasetMeta = {
50
- controller: {
51
- chart: {
52
- scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } },
53
- options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } },
54
- chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 },
55
- data: { labels: [] }
56
- }
57
- },
58
- data: [{x:0, y:0}]
59
- };
60
- mockXScale = {
61
- getPixelForValue: jest.fn(val => val * 10),
62
- getValueForPixel: jest.fn(pixel => pixel / 10),
63
- options: { type: 'linear' }
64
- };
65
- mockYScale = { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) };
66
-
67
- mockDataset = {
68
- data: [ { x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 } ],
69
- yAxisID: 'y',
70
- borderColor: 'blue', borderWidth: 2,
71
- trendlineLinear: {
72
- colorMin: 'red', colorMax: 'red', width: 3, lineStyle: 'dashed',
73
- fillColor: false, trendoffset: 0, projection: false,
74
- xAxisKey: 'x', yAxisKey: 'y',
75
- label: { display: true, text: 'My Trend', color: 'black', offset: 5, displayValue: true, percentage: false, font: { family: 'Arial', size: 12 } }
76
- }
77
- };
78
- });
79
-
80
- test('Scenario 1: Simple linear data, no projection, no offset', () => {
81
- mockDatasetMeta.data = [{x:10, y:30}];
82
- mockLineFitterInstance.minx = 10;
83
- mockLineFitterInstance.maxx = 30;
84
- mockLineFitterInstance.count = 3;
85
- mockLineFitterInstance.f = jest.fn(x => {
86
- if (x === 10) return 21;
87
- if (x === 30) return 61;
88
- return 2*x+1;
89
- });
90
- mockXScale.getPixelForValue = jest.fn(val => {
91
- if (val === 10) return 100;
92
- if (val === 30) return 300;
93
- return val*10;
94
- });
95
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
96
- if (val === 21) return 210;
97
- if (val === 61) return 610;
98
- return val*10;
99
- });
100
-
101
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
102
-
103
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
104
- // Expecting clipped coordinates based on previous log analysis {"x1":100,"y1":210,"x2":220,"y2":450}
105
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
106
- expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
107
- });
108
-
109
- test('Scenario 2: Data with null or undefined values', () => {
110
- mockDataset.data = [ { x: 10, y: 30 }, null, { x: 20, y: 50 }, undefined, { x: 30, y: 70 } ];
111
- mockDatasetMeta.data = [{x:10, y:30}];
112
- mockLineFitterInstance.minx = 10;
113
- mockLineFitterInstance.maxx = 30;
114
- mockLineFitterInstance.count = 3;
115
- mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : 2 * x + 1)));
116
- mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : val*10) ));
117
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : val*10)));
118
-
119
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
120
-
121
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
122
- // Expecting clipped coordinates as per Scenario 1, since data and chartArea are similar enough
123
- // for the same clipping to occur on the unclipped (100,210)-(300,610) line.
124
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
125
- expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
126
- });
127
-
128
- test('Scenario 3: trendlineLinear.projection is true', () => {
129
- mockDataset.trendlineLinear.projection = true;
130
- mockDatasetMeta.data = [{x:10, y:30}];
131
- mockLineFitterInstance.count = 3;
132
- mockLineFitterInstance.slope = jest.fn(() => 2);
133
- mockLineFitterInstance.intercept = jest.fn(() => 1);
134
- mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
135
-
136
- // Expected data values for intersections based on y=2x+1 and chartArea {t:50,b:450,l:50,r:750}, scales val = px/10
137
- // For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
138
- // Valid intersection points (data values): (5,11) and (22,45)
139
- mockXScale.getPixelForValue = jest.fn(val => {
140
- if (val === 5) return 50;
141
- if (val === 22) return 220;
142
- return NaN;
143
- });
144
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
145
- if (val === 11) return 110;
146
- if (val === 45) return 450;
147
- return NaN;
148
- });
149
- mockXScale.getValueForPixel = jest.fn(pixel => {
150
- if (pixel === mockDatasetMeta.controller.chart.chartArea.left) return 5;
151
- if (pixel === mockDatasetMeta.controller.chart.chartArea.right) return 75;
152
- return NaN;
153
- });
154
- // This mock ensures that chartMinY/MaxY are correctly ordered for the filter
155
- // based on the corrected logic in trendline.js (Math.min/max of top/bottom data values)
156
- mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => {
157
- if (pixel === mockDatasetMeta.controller.chart.chartArea.top) return 5;
158
- if (pixel === mockDatasetMeta.controller.chart.chartArea.bottom) return 45;
159
- return NaN;
160
- });
161
-
162
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
163
-
164
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 50, y1: 110, x2: 220, y2: 450 }));
165
- });
166
-
167
- test('Scenario 3b: trendlineLinear.projection is true (negative slope)', () => {
168
- mockDataset.trendlineLinear.projection = true;
169
- mockDatasetMeta.data = [{x:10, y:30}];
170
- mockLineFitterInstance.count = 3;
171
- mockLineFitterInstance.slope = jest.fn(() => -2);
172
- mockLineFitterInstance.intercept = jest.fn(() => 100);
173
- mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
174
-
175
- // Expected data values for intersections based on y=-2x+100
176
- // For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
177
- // Valid intersection points (data values): (27.5,45) and (47.5,5)
178
- mockXScale.getPixelForValue = jest.fn(val => {
179
- if (val === 27.5) return 275;
180
- if (val === 47.5) return 475;
181
- return NaN;
182
- });
183
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
184
- if (val === 45) return 450;
185
- if (val === 5) return 50;
186
- return NaN;
187
- });
188
- mockXScale.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 750 ? 75 : NaN)));
189
- mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 450 ? 45 : NaN)));
190
-
191
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
192
-
193
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 275, y1: 450, x2: 475, y2: 50 }));
194
- });
195
-
196
- test('Scenario 4: trendoffset (positive)', () => {
197
- mockDataset.trendlineLinear.trendoffset = 1;
198
- mockDataset.data = [{ x: 5, y: 10 }, { x: 10, y: 30 }, { x: 20, y: 50 }];
199
- mockDatasetMeta.data = [{x:5, y:10}];
200
- mockLineFitterInstance.minx = 10;
201
- mockLineFitterInstance.maxx = 20;
202
- mockLineFitterInstance.count = 2;
203
- mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
204
- mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
205
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
206
-
207
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
208
-
209
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
210
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
211
- });
212
-
213
- test('Scenario 5: trendoffset (negative)', () => {
214
- mockDataset.trendlineLinear.trendoffset = -1;
215
- mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
216
- mockDatasetMeta.data = [{x:10, y:30}];
217
- mockLineFitterInstance.minx = 10;
218
- mockLineFitterInstance.maxx = 20;
219
- mockLineFitterInstance.count = 2;
220
- mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
221
- mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
222
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
223
-
224
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
225
-
226
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
227
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
228
- });
229
-
230
- test('Scenario 5b: trendoffset (negative) to exclude all but one point', () => {
231
- mockDataset.trendlineLinear.trendoffset = -2;
232
- mockDataset.data = [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }];
233
- mockDatasetMeta.data = [{x:0, y:0}];
234
- mockLineFitterInstance.minx = 0;
235
- mockLineFitterInstance.maxx = 0;
236
- mockLineFitterInstance.count = 1;
237
-
238
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
239
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
240
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0,0);
241
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
242
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
243
- });
244
-
245
- test('Scenario 5c: trendoffset (negative) where it makes firstIndex search start beyond array bounds', () => {
246
- mockDataset.trendlineLinear.trendoffset = -3;
247
- mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
248
- mockDatasetMeta.data = [{x:10, y:30}];
249
- mockLineFitterInstance.count = 3;
250
- mockLineFitterInstance.minx = 10;
251
- mockLineFitterInstance.maxx = 30;
252
- mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
253
- mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
254
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
255
-
256
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
257
-
258
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
259
- // Expect clipped coordinates based on Scenario 1's log output
260
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
261
- });
262
-
263
- test('Scenario 6: fillColor is true', () => {
264
- mockDataset.trendlineLinear.fillColor = 'rgba(0,0,255,0.1)';
265
- mockDatasetMeta.data = [{x:10, y:30}];
266
- mockLineFitterInstance.minx = 10;
267
- mockLineFitterInstance.maxx = 30;
268
- mockLineFitterInstance.count = 3;
269
- mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
270
- mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
271
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
272
-
273
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
274
-
275
- expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledTimes(1);
276
- // Expect clipped coordinates based on Scenario 1's log output
277
- expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith(
278
- mockCtx, 100, 210, 220, 450,
279
- mockDatasetMeta.controller.chart.chartArea.bottom,
280
- mockDataset.trendlineLinear.fillColor
281
- );
282
- });
283
-
284
- test('Handles time scale data correctly', () => {
285
- mockXScale.options.type = 'timeseries';
286
- const date1 = new Date('2023-01-01T00:00:00.000Z');
287
- const date2 = new Date('2023-01-02T00:00:00.000Z');
288
- const date1Str = date1.toISOString();
289
- const date2Str = date2.toISOString();
290
- mockDataset.data = [ { x: date1Str, y: 10 }, { t: date2Str, y: 20 } ];
291
- mockDataset.trendlineLinear.xAxisKey = 'x';
292
- mockDatasetMeta.data = [{ x: date1Str, y: 10 }];
293
-
294
- const date1Ts = date1.getTime();
295
- const date2Ts = date2.getTime();
296
- mockLineFitterInstance.minx = date1Ts;
297
- mockLineFitterInstance.maxx = date2Ts;
298
- mockLineFitterInstance.count = 2;
299
- mockLineFitterInstance.f = jest.fn(t => {
300
- if (t === date1Ts) return 1.0;
301
- if (t === date2Ts) return 2.0;
302
- return NaN;
303
- });
304
- mockXScale.getPixelForValue = jest.fn(t => {
305
- if (t === date1Ts) return 50;
306
- if (t === date2Ts) return 150;
307
- return NaN;
308
- });
309
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
310
- if (val === 1.0) return 10;
311
- if (val === 2.0) return 20;
312
- return NaN;
313
- });
314
-
315
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
316
-
317
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
318
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1.getTime(), 10);
319
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 20);
320
- // The line (50,10) to (150,20) is outside chartArea.top=50, so it should be clipped out.
321
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
322
- });
323
-
324
- test('Handles data where x or y might be missing for object-type data but not timeseries', () => {
325
- mockXScale.options.type = 'linear';
326
- mockDataset.data = [ { x: 10, y: 30 }, { x: 20 }, { y: 70 }, { value: 90 } ];
327
- mockDatasetMeta.data = mockDataset.data;
328
- mockLineFitterInstance.count = 1;
329
- mockLineFitterInstance.minx = 10;
330
- mockLineFitterInstance.maxx = 10;
331
-
332
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
333
-
334
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
335
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(10, 30);
336
- expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
337
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
338
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
339
- });
340
-
341
- test('Uses parsingOptions for xAxisKey and yAxisKey if specific ones are not provided', () => {
342
- delete mockDataset.trendlineLinear.xAxisKey;
343
- delete mockDataset.trendlineLinear.yAxisKey;
344
- mockDatasetMeta.controller.chart.options.parsing = { xAxisKey: 'customX', yAxisKey: 'customY' };
345
- mockDataset.data = [{ customX: 5, customY: 15 }];
346
- mockDatasetMeta.data = [{customX: 5}];
347
- mockLineFitterInstance.count = 1;
348
- mockLineFitterInstance.minx = 5;
349
- mockLineFitterInstance.maxx = 5;
350
-
351
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
352
-
353
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
354
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(5, 15);
355
- expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
356
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
357
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
358
- });
359
-
360
- test('Configuration without label property (issue #118)', () => {
361
- // Test case for user's exact configuration that was failing
362
- mockDataset.trendlineLinear = {
363
- lineStyle: 'dotted',
364
- width: 2
365
- };
366
- mockDataset.data = [10, 20, 30, 40, 50];
367
- mockDatasetMeta.data = [10, 20, 30, 40, 50];
368
- mockLineFitterInstance.minx = 0;
369
- mockLineFitterInstance.maxx = 4;
370
- mockLineFitterInstance.count = 5;
371
- mockLineFitterInstance.f = jest.fn(x => {
372
- if (x === 0) return 100;
373
- if (x === 4) return 200;
374
- return x * 25 + 100;
375
- });
376
- mockXScale.getPixelForValue = jest.fn(val => {
377
- if (val === 0) return 100;
378
- if (val === 4) return 400;
379
- return val * 100 + 100;
380
- });
381
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
382
- if (val === 100) return 200;
383
- if (val === 200) return 300;
384
- return val * 1 + 100;
385
- });
386
-
387
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
388
-
389
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
390
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0, 10);
391
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(1, 20);
392
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(2, 30);
393
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(3, 40);
394
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(4, 50);
395
- expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
396
- expect(drawingUtils.drawTrendline).toHaveBeenCalled();
397
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
398
- });
399
-
400
- test('Configuration without label property but with other properties', () => {
401
- // Test minimal configuration similar to issue #118
402
- mockDataset.trendlineLinear = {
403
- colorMin: 'red',
404
- width: 3,
405
- lineStyle: 'dashed'
406
- };
407
- mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }];
408
- mockDatasetMeta.data = [{ x: 10, y: 30 }];
409
- mockLineFitterInstance.minx = 10;
410
- mockLineFitterInstance.maxx = 20;
411
- mockLineFitterInstance.count = 2;
412
- mockLineFitterInstance.f = jest.fn(x => {
413
- if (x === 10) return 30;
414
- if (x === 20) return 50;
415
- return x * 2 + 10;
416
- });
417
- mockXScale.getPixelForValue = jest.fn(val => {
418
- if (val === 10) return 100;
419
- if (val === 20) return 200;
420
- return val * 10;
421
- });
422
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
423
- if (val === 30) return 200;
424
- if (val === 50) return 300;
425
- return val * 10;
426
- });
427
-
428
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
429
-
430
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
431
- expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed');
432
- expect(drawingUtils.drawTrendline).toHaveBeenCalled();
433
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
434
- });
435
-
436
- test('Time scale with array of numbers and no label property (issue #118)', () => {
437
- // Test case for time scale with array data (like lineChartTypeTime.html)
438
- mockXScale.options.type = 'time';
439
- mockDataset.trendlineLinear = {
440
- lineStyle: 'dotted',
441
- width: 2
442
- };
443
- mockDataset.data = [75, 64, 52, 23, 44]; // Just numbers like in the example
444
- mockDatasetMeta.data = [75, 64, 52, 23, 44];
445
- mockDatasetMeta.controller.chart.data.labels = [
446
- '2025-03-01T00:00:00',
447
- '2025-03-02T00:00:00',
448
- '2025-03-03T00:00:00',
449
- '2025-03-04T00:00:00',
450
- '2025-03-05T00:00:00'
451
- ];
452
-
453
- const date1 = new Date('2025-03-01T00:00:00').getTime();
454
- const date5 = new Date('2025-03-05T00:00:00').getTime();
455
-
456
- mockLineFitterInstance.minx = date1;
457
- mockLineFitterInstance.maxx = date5;
458
- mockLineFitterInstance.count = 5;
459
- mockLineFitterInstance.f = jest.fn(x => {
460
- if (x === date1) return 75;
461
- if (x === date5) return 44;
462
- return 50; // approximation
463
- });
464
- mockXScale.getPixelForValue = jest.fn(val => {
465
- if (val === date1) return 100;
466
- if (val === date5) return 300;
467
- return 200;
468
- });
469
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
470
- if (val === 75) return 200;
471
- if (val === 44) return 250;
472
- return 225;
473
- });
474
-
475
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
476
-
477
- expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
478
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1, 75);
479
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-02T00:00:00').getTime(), 64);
480
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-03T00:00:00').getTime(), 52);
481
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-04T00:00:00').getTime(), 23);
482
- expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-05T00:00:00').getTime(), 44);
483
- expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
484
- expect(drawingUtils.drawTrendline).toHaveBeenCalled();
485
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
486
- });
487
- });
488
-
489
- describe('addFitter - Exponential Trendlines', () => {
490
- let mockCtx;
491
- let mockDatasetMeta;
492
- let mockDataset;
493
- let mockXScale;
494
- let mockYScale;
495
- let mockExponentialFitterInstance;
496
-
497
- beforeEach(() => {
498
- jest.clearAllMocks();
499
-
500
- // Mock ExponentialFitter instance
501
- mockExponentialFitterInstance = {
502
- add: jest.fn(),
503
- f: jest.fn(x => 2 * Math.exp(0.5 * x)), // Example: y = 2 * e^(0.5x)
504
- coefficient: jest.fn(() => 2),
505
- growthRate: jest.fn(() => 0.5),
506
- scale: jest.fn(() => 0.5),
507
- minx: undefined,
508
- maxx: undefined,
509
- count: 0,
510
- hasValidData: true
511
- };
512
- ExponentialFitter.mockImplementation(() => mockExponentialFitterInstance);
513
-
514
- mockCtx = {
515
- save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(),
516
- measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '',
517
- strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(),
518
- lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(), setLineDash: jest.fn(),
519
- };
520
-
521
- mockDatasetMeta = {
522
- controller: {
523
- chart: {
524
- scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } },
525
- options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } },
526
- chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 },
527
- data: { labels: [] }
528
- }
529
- },
530
- data: [{x:0, y:0}]
531
- };
532
-
533
- mockXScale = {
534
- getPixelForValue: jest.fn(val => val * 10),
535
- getValueForPixel: jest.fn(pixel => pixel / 10),
536
- options: { type: 'linear' }
537
- };
538
-
539
- mockYScale = {
540
- getPixelForValue: jest.fn(val => val * 10),
541
- getValueForPixel: jest.fn(pixel => pixel / 10)
542
- };
543
-
544
- mockDataset = {
545
- data: [ { x: 0, y: 2 }, { x: 1, y: 3.3 }, { x: 2, y: 5.4 } ], // Exponential-like data
546
- yAxisID: 'y',
547
- borderColor: 'blue',
548
- borderWidth: 2,
549
- trendlineExponential: {
550
- colorMin: 'red',
551
- colorMax: 'red',
552
- width: 3,
553
- lineStyle: 'solid',
554
- fillColor: false,
555
- trendoffset: 0,
556
- projection: false,
557
- xAxisKey: 'x',
558
- yAxisKey: 'y',
559
- label: {
560
- display: true,
561
- text: 'Exponential Trend',
562
- color: 'black',
563
- offset: 5,
564
- displayValue: true,
565
- font: { family: 'Arial', size: 12 }
566
- }
567
- }
568
- };
569
- });
570
-
571
- test('Basic exponential trendline rendering', () => {
572
- mockDatasetMeta.data = [{x:0, y:2}];
573
- mockExponentialFitterInstance.minx = 0;
574
- mockExponentialFitterInstance.maxx = 2;
575
- mockExponentialFitterInstance.count = 3;
576
- mockExponentialFitterInstance.f = jest.fn(x => {
577
- if (x === 0) return 2;
578
- if (x === 2) return 5.4;
579
- return 2 * Math.exp(0.5 * x);
580
- });
581
-
582
- mockXScale.getPixelForValue = jest.fn(val => {
583
- if (val === 0) return 50;
584
- if (val === 2) return 250;
585
- return val * 100 + 50;
586
- });
587
-
588
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
589
- if (val === 2) return 200;
590
- if (val === 5.4) return 340;
591
- return val * 100;
592
- });
593
-
594
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
595
-
596
- expect(ExponentialFitter).toHaveBeenCalledTimes(1);
597
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3);
598
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(0, 2);
599
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 3.3);
600
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 5.4);
601
- expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({
602
- x1: 50, y1: 200, x2: 250, y2: 340
603
- }));
604
- });
605
-
606
- test('Exponential trendline with label showing coefficient and growth rate', () => {
607
- mockDatasetMeta.data = [{x:0, y:2}];
608
- mockExponentialFitterInstance.minx = 0;
609
- mockExponentialFitterInstance.maxx = 2;
610
- mockExponentialFitterInstance.count = 3;
611
- mockExponentialFitterInstance.coefficient = jest.fn(() => 2.05);
612
- mockExponentialFitterInstance.growthRate = jest.fn(() => 0.48);
613
- mockExponentialFitterInstance.f = jest.fn(x => {
614
- if (x === 0) return 2;
615
- if (x === 2) return 5.4;
616
- return 2.05 * Math.exp(0.48 * x);
617
- });
618
-
619
- mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
620
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
621
-
622
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
623
-
624
- expect(labelUtils.addTrendlineLabel).toHaveBeenCalledWith(
625
- mockCtx,
626
- 'Exponential Trend (a=2.05, b=0.48)',
627
- expect.any(Number),
628
- expect.any(Number),
629
- expect.any(Number),
630
- expect.any(Number),
631
- expect.any(Number),
632
- 'black',
633
- 'Arial',
634
- 12,
635
- 5
636
- );
637
- });
638
-
639
- test('Exponential trendline checks projection configuration', () => {
640
- mockDataset.trendlineExponential.projection = true;
641
- mockDatasetMeta.data = [{x:0, y:2}];
642
- mockExponentialFitterInstance.minx = 0;
643
- mockExponentialFitterInstance.maxx = 2;
644
- mockExponentialFitterInstance.count = 3;
645
- mockExponentialFitterInstance.f = jest.fn(x => 2 * Math.exp(0.5 * x));
646
-
647
- mockXScale.getValueForPixel = jest.fn(pixel => pixel / 100);
648
- mockXScale.getPixelForValue = jest.fn(val => val * 100);
649
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
650
- mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => pixel / 100);
651
-
652
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
653
-
654
- // Just verify that projection mode calls the boundary functions
655
- expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(50); // left boundary
656
- expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(750); // right boundary
657
- });
658
-
659
- test('Exponential trendline with fill color', () => {
660
- mockDataset.trendlineExponential.fillColor = 'rgba(255,0,0,0.2)';
661
- mockDatasetMeta.data = [{x:0, y:2}];
662
- mockExponentialFitterInstance.minx = 0;
663
- mockExponentialFitterInstance.maxx = 2;
664
- mockExponentialFitterInstance.count = 3;
665
- mockExponentialFitterInstance.f = jest.fn(x => {
666
- if (x === 0) return 2;
667
- if (x === 2) return 5.4;
668
- return 2 * Math.exp(0.5 * x);
669
- });
670
-
671
- mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
672
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
673
-
674
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
675
-
676
- expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith(
677
- mockCtx,
678
- expect.any(Number), // x1
679
- expect.any(Number), // y1
680
- expect.any(Number), // x2
681
- expect.any(Number), // y2
682
- mockDatasetMeta.controller.chart.chartArea.bottom,
683
- 'rgba(255,0,0,0.2)'
684
- );
685
- });
686
-
687
- test('Exponential trendline with insufficient data points', () => {
688
- mockDataset.data = [{ x: 0, y: 2 }]; // Only one data point
689
- mockExponentialFitterInstance.count = 1;
690
-
691
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
692
-
693
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
694
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
695
- });
696
-
697
- test('Exponential trendline with trendoffset', () => {
698
- mockDataset.trendlineExponential.trendoffset = 1;
699
- mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 4 }];
700
- mockDatasetMeta.data = [{x:0, y:1}];
701
- mockExponentialFitterInstance.minx = 1;
702
- mockExponentialFitterInstance.maxx = 2;
703
- mockExponentialFitterInstance.count = 2;
704
- mockExponentialFitterInstance.f = jest.fn(x => {
705
- if (x === 1) return 2;
706
- if (x === 2) return 4;
707
- return Math.exp(x);
708
- });
709
-
710
- mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
711
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
712
-
713
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
714
-
715
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(2);
716
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 2);
717
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 4);
718
- expect(mockExponentialFitterInstance.add).not.toHaveBeenCalledWith(0, 1);
719
- });
720
-
721
- test('Exponential trendline with invalid data (hasValidData = false)', () => {
722
- mockExponentialFitterInstance.hasValidData = false;
723
- mockExponentialFitterInstance.count = 3;
724
-
725
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
726
-
727
- expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
728
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
729
- });
730
-
731
- test('Exponential trendline with time scale', () => {
732
- mockXScale.options.type = 'time';
733
- const date1 = new Date('2023-01-01T00:00:00.000Z');
734
- const date2 = new Date('2023-01-02T00:00:00.000Z');
735
- const date3 = new Date('2023-01-03T00:00:00.000Z');
736
-
737
- mockDataset.data = [
738
- { x: date1.toISOString(), y: 2 },
739
- { x: date2.toISOString(), y: 4 },
740
- { x: date3.toISOString(), y: 8 }
741
- ];
742
- mockDatasetMeta.data = [{ x: date1.toISOString(), y: 2 }];
743
-
744
- const date1Ts = date1.getTime();
745
- const date3Ts = date3.getTime();
746
-
747
- mockExponentialFitterInstance.minx = date1Ts;
748
- mockExponentialFitterInstance.maxx = date3Ts;
749
- mockExponentialFitterInstance.count = 3;
750
- mockExponentialFitterInstance.f = jest.fn(x => {
751
- if (x === date1Ts) return 2;
752
- if (x === date3Ts) return 8;
753
- return 2 * Math.exp(0.693 * (x - date1Ts) / (24 * 60 * 60 * 1000));
754
- });
755
-
756
- mockXScale.getPixelForValue = jest.fn(val => val / 1000000);
757
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
758
-
759
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
760
-
761
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3);
762
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date1Ts, 2);
763
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 4);
764
- expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date3Ts, 8);
765
- });
766
-
767
- test('Exponential trendline with minimal configuration (no label)', () => {
768
- mockDataset.trendlineExponential = {
769
- colorMin: 'blue',
770
- width: 2,
771
- lineStyle: 'dashed'
772
- };
773
- mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }];
774
- mockDatasetMeta.data = [{x:0, y:1}];
775
- mockExponentialFitterInstance.minx = 0;
776
- mockExponentialFitterInstance.maxx = 1;
777
- mockExponentialFitterInstance.count = 2;
778
- mockExponentialFitterInstance.f = jest.fn(x => Math.exp(x));
779
-
780
- mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
781
- mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
782
-
783
- addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
784
-
785
- expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed');
786
- expect(drawingUtils.drawTrendline).toHaveBeenCalled();
787
- expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
788
- });
789
- });
1
+ import { addFitter } from './trendline.js';
2
+ import 'jest-canvas-mock';
3
+
4
+ import { LineFitter } from '../utils/lineFitter.js';
5
+ import { ExponentialFitter } from '../utils/exponentialFitter.js';
6
+ import * as drawingUtils from '../utils/drawing.js';
7
+ import * as labelUtils from './label.js';
8
+
9
+ jest.mock('../utils/lineFitter');
10
+ jest.mock('../utils/exponentialFitter');
11
+ jest.mock('../utils/drawing', () => ({
12
+ drawTrendline: jest.fn(),
13
+ fillBelowTrendline: jest.fn(),
14
+ setLineStyle: jest.fn(),
15
+ }));
16
+ jest.mock('./label', () => ({ addTrendlineLabel: jest.fn() }));
17
+
18
+ describe('addFitter', () => {
19
+ let mockCtx;
20
+ let mockDatasetMeta;
21
+ let mockDataset;
22
+ let mockXScale;
23
+ let mockYScale;
24
+ let mockLineFitterInstance;
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ mockLineFitterInstance = {
29
+ add: jest.fn(),
30
+ f: jest.fn(x => x * 2 + 1),
31
+ slope: jest.fn(() => 2),
32
+ intercept: jest.fn(() => 1),
33
+ fo: jest.fn(() => 50),
34
+ scale: jest.fn(() => 1),
35
+ minx: undefined,
36
+ maxx: undefined,
37
+ count: 0,
38
+ sumx: 0, sumy: 0, sumx2: 0, sumxy: 0,
39
+ };
40
+ LineFitter.mockImplementation(() => mockLineFitterInstance);
41
+
42
+ mockCtx = {
43
+ save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(),
44
+ measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '',
45
+ strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(),
46
+ lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(),
47
+ };
48
+
49
+ mockDatasetMeta = {
50
+ controller: {
51
+ chart: {
52
+ scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } },
53
+ options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } },
54
+ chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 },
55
+ data: { labels: [] }
56
+ }
57
+ },
58
+ data: [{x:0, y:0}]
59
+ };
60
+ mockXScale = {
61
+ getPixelForValue: jest.fn(val => val * 10),
62
+ getValueForPixel: jest.fn(pixel => pixel / 10),
63
+ options: { type: 'linear' }
64
+ };
65
+ mockYScale = { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) };
66
+
67
+ mockDataset = {
68
+ data: [ { x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 } ],
69
+ yAxisID: 'y',
70
+ borderColor: 'blue', borderWidth: 2,
71
+ trendlineLinear: {
72
+ colorMin: 'red', colorMax: 'red', width: 3, lineStyle: 'dashed',
73
+ fillColor: false, trendoffset: 0, projection: false,
74
+ xAxisKey: 'x', yAxisKey: 'y',
75
+ label: { display: true, text: 'My Trend', color: 'black', offset: 5, displayValue: true, percentage: false, font: { family: 'Arial', size: 12 } }
76
+ }
77
+ };
78
+ });
79
+
80
+ test('Scenario 1: Simple linear data, no projection, no offset', () => {
81
+ mockDatasetMeta.data = [{x:10, y:30}];
82
+ mockLineFitterInstance.minx = 10;
83
+ mockLineFitterInstance.maxx = 30;
84
+ mockLineFitterInstance.count = 3;
85
+ mockLineFitterInstance.f = jest.fn(x => {
86
+ if (x === 10) return 21;
87
+ if (x === 30) return 61;
88
+ return 2*x+1;
89
+ });
90
+ mockXScale.getPixelForValue = jest.fn(val => {
91
+ if (val === 10) return 100;
92
+ if (val === 30) return 300;
93
+ return val*10;
94
+ });
95
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
96
+ if (val === 21) return 210;
97
+ if (val === 61) return 610;
98
+ return val*10;
99
+ });
100
+
101
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
102
+
103
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
104
+ // Expecting clipped coordinates based on previous log analysis {"x1":100,"y1":210,"x2":220,"y2":450}
105
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
106
+ expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ test('Scenario 2: Data with null or undefined values', () => {
110
+ mockDataset.data = [ { x: 10, y: 30 }, null, { x: 20, y: 50 }, undefined, { x: 30, y: 70 } ];
111
+ mockDatasetMeta.data = [{x:10, y:30}];
112
+ mockLineFitterInstance.minx = 10;
113
+ mockLineFitterInstance.maxx = 30;
114
+ mockLineFitterInstance.count = 3;
115
+ mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : 2 * x + 1)));
116
+ mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : val*10) ));
117
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : val*10)));
118
+
119
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
120
+
121
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
122
+ // Expecting clipped coordinates as per Scenario 1, since data and chartArea are similar enough
123
+ // for the same clipping to occur on the unclipped (100,210)-(300,610) line.
124
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
125
+ expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ test('Scenario 3: trendlineLinear.projection is true', () => {
129
+ mockDataset.trendlineLinear.projection = true;
130
+ mockDatasetMeta.data = [{x:10, y:30}];
131
+ mockLineFitterInstance.count = 3;
132
+ mockLineFitterInstance.slope = jest.fn(() => 2);
133
+ mockLineFitterInstance.intercept = jest.fn(() => 1);
134
+ mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
135
+
136
+ // Expected data values for intersections based on y=2x+1 and chartArea {t:50,b:450,l:50,r:750}, scales val = px/10
137
+ // For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
138
+ // Valid intersection points (data values): (5,11) and (22,45)
139
+ mockXScale.getPixelForValue = jest.fn(val => {
140
+ if (val === 5) return 50;
141
+ if (val === 22) return 220;
142
+ return NaN;
143
+ });
144
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
145
+ if (val === 11) return 110;
146
+ if (val === 45) return 450;
147
+ return NaN;
148
+ });
149
+ mockXScale.getValueForPixel = jest.fn(pixel => {
150
+ if (pixel === mockDatasetMeta.controller.chart.chartArea.left) return 5;
151
+ if (pixel === mockDatasetMeta.controller.chart.chartArea.right) return 75;
152
+ return NaN;
153
+ });
154
+ // This mock ensures that chartMinY/MaxY are correctly ordered for the filter
155
+ // based on the corrected logic in trendline.js (Math.min/max of top/bottom data values)
156
+ mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => {
157
+ if (pixel === mockDatasetMeta.controller.chart.chartArea.top) return 5;
158
+ if (pixel === mockDatasetMeta.controller.chart.chartArea.bottom) return 45;
159
+ return NaN;
160
+ });
161
+
162
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
163
+
164
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 50, y1: 110, x2: 220, y2: 450 }));
165
+ });
166
+
167
+ test('Scenario 3b: trendlineLinear.projection is true (negative slope)', () => {
168
+ mockDataset.trendlineLinear.projection = true;
169
+ mockDatasetMeta.data = [{x:10, y:30}];
170
+ mockLineFitterInstance.count = 3;
171
+ mockLineFitterInstance.slope = jest.fn(() => -2);
172
+ mockLineFitterInstance.intercept = jest.fn(() => 100);
173
+ mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
174
+
175
+ // Expected data values for intersections based on y=-2x+100
176
+ // For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
177
+ // Valid intersection points (data values): (27.5,45) and (47.5,5)
178
+ mockXScale.getPixelForValue = jest.fn(val => {
179
+ if (val === 27.5) return 275;
180
+ if (val === 47.5) return 475;
181
+ return NaN;
182
+ });
183
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
184
+ if (val === 45) return 450;
185
+ if (val === 5) return 50;
186
+ return NaN;
187
+ });
188
+ mockXScale.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 750 ? 75 : NaN)));
189
+ mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 450 ? 45 : NaN)));
190
+
191
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
192
+
193
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 275, y1: 450, x2: 475, y2: 50 }));
194
+ });
195
+
196
+ test('Scenario 4: trendoffset (positive)', () => {
197
+ mockDataset.trendlineLinear.trendoffset = 1;
198
+ mockDataset.data = [{ x: 5, y: 10 }, { x: 10, y: 30 }, { x: 20, y: 50 }];
199
+ mockDatasetMeta.data = [{x:5, y:10}];
200
+ mockLineFitterInstance.minx = 10;
201
+ mockLineFitterInstance.maxx = 20;
202
+ mockLineFitterInstance.count = 2;
203
+ mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
204
+ mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
205
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
206
+
207
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
208
+
209
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
210
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
211
+ });
212
+
213
+ test('Scenario 5: trendoffset (negative)', () => {
214
+ mockDataset.trendlineLinear.trendoffset = -1;
215
+ mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
216
+ mockDatasetMeta.data = [{x:10, y:30}];
217
+ mockLineFitterInstance.minx = 10;
218
+ mockLineFitterInstance.maxx = 20;
219
+ mockLineFitterInstance.count = 2;
220
+ mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
221
+ mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
222
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
223
+
224
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
225
+
226
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
227
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
228
+ });
229
+
230
+ test('Scenario 5b: trendoffset (negative) to exclude all but one point', () => {
231
+ mockDataset.trendlineLinear.trendoffset = -2;
232
+ mockDataset.data = [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }];
233
+ mockDatasetMeta.data = [{x:0, y:0}];
234
+ mockLineFitterInstance.minx = 0;
235
+ mockLineFitterInstance.maxx = 0;
236
+ mockLineFitterInstance.count = 1;
237
+
238
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
239
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
240
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0,0);
241
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
242
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
243
+ });
244
+
245
+ test('Scenario 5c: trendoffset (negative) where it makes firstIndex search start beyond array bounds', () => {
246
+ mockDataset.trendlineLinear.trendoffset = -3;
247
+ mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
248
+ mockDatasetMeta.data = [{x:10, y:30}];
249
+ mockLineFitterInstance.count = 3;
250
+ mockLineFitterInstance.minx = 10;
251
+ mockLineFitterInstance.maxx = 30;
252
+ mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
253
+ mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
254
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
255
+
256
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
257
+
258
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
259
+ // Expect clipped coordinates based on Scenario 1's log output
260
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
261
+ });
262
+
263
+ test('Scenario 6: fillColor is true', () => {
264
+ mockDataset.trendlineLinear.fillColor = 'rgba(0,0,255,0.1)';
265
+ mockDatasetMeta.data = [{x:10, y:30}];
266
+ mockLineFitterInstance.minx = 10;
267
+ mockLineFitterInstance.maxx = 30;
268
+ mockLineFitterInstance.count = 3;
269
+ mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
270
+ mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
271
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
272
+
273
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
274
+
275
+ expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledTimes(1);
276
+ // Expect clipped coordinates based on Scenario 1's log output
277
+ expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith(
278
+ mockCtx, 100, 210, 220, 450,
279
+ mockDatasetMeta.controller.chart.chartArea.bottom,
280
+ mockDataset.trendlineLinear.fillColor
281
+ );
282
+ });
283
+
284
+ test('Handles time scale data correctly', () => {
285
+ mockXScale.options.type = 'timeseries';
286
+ const date1 = new Date('2023-01-01T00:00:00.000Z');
287
+ const date2 = new Date('2023-01-02T00:00:00.000Z');
288
+ const date1Str = date1.toISOString();
289
+ const date2Str = date2.toISOString();
290
+ mockDataset.data = [ { x: date1Str, y: 10 }, { t: date2Str, y: 20 } ];
291
+ mockDataset.trendlineLinear.xAxisKey = 'x';
292
+ mockDatasetMeta.data = [{ x: date1Str, y: 10 }];
293
+
294
+ const date1Ts = date1.getTime();
295
+ const date2Ts = date2.getTime();
296
+ mockLineFitterInstance.minx = date1Ts;
297
+ mockLineFitterInstance.maxx = date2Ts;
298
+ mockLineFitterInstance.count = 2;
299
+ mockLineFitterInstance.f = jest.fn(t => {
300
+ if (t === date1Ts) return 1.0;
301
+ if (t === date2Ts) return 2.0;
302
+ return NaN;
303
+ });
304
+ mockXScale.getPixelForValue = jest.fn(t => {
305
+ if (t === date1Ts) return 50;
306
+ if (t === date2Ts) return 150;
307
+ return NaN;
308
+ });
309
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
310
+ if (val === 1.0) return 10;
311
+ if (val === 2.0) return 20;
312
+ return NaN;
313
+ });
314
+
315
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
316
+
317
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
318
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1.getTime(), 10);
319
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 20);
320
+ // The line (50,10) to (150,20) is outside chartArea.top=50, so it should be clipped out.
321
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
322
+ });
323
+
324
+ test('Handles data where x or y might be missing for object-type data but not timeseries', () => {
325
+ mockXScale.options.type = 'linear';
326
+ mockDataset.data = [ { x: 10, y: 30 }, { x: 20 }, { y: 70 }, { value: 90 } ];
327
+ mockDatasetMeta.data = mockDataset.data;
328
+ mockLineFitterInstance.count = 1;
329
+ mockLineFitterInstance.minx = 10;
330
+ mockLineFitterInstance.maxx = 10;
331
+
332
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
333
+
334
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
335
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(10, 30);
336
+ expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
337
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
338
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
339
+ });
340
+
341
+ test('Uses parsingOptions for xAxisKey and yAxisKey if specific ones are not provided', () => {
342
+ delete mockDataset.trendlineLinear.xAxisKey;
343
+ delete mockDataset.trendlineLinear.yAxisKey;
344
+ mockDatasetMeta.controller.chart.options.parsing = { xAxisKey: 'customX', yAxisKey: 'customY' };
345
+ mockDataset.data = [{ customX: 5, customY: 15 }];
346
+ mockDatasetMeta.data = [{customX: 5}];
347
+ mockLineFitterInstance.count = 1;
348
+ mockLineFitterInstance.minx = 5;
349
+ mockLineFitterInstance.maxx = 5;
350
+
351
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
352
+
353
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
354
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(5, 15);
355
+ expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
356
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
357
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
358
+ });
359
+
360
+ test('Configuration without label property (issue #118)', () => {
361
+ // Test case for user's exact configuration that was failing
362
+ mockDataset.trendlineLinear = {
363
+ lineStyle: 'dotted',
364
+ width: 2
365
+ };
366
+ mockDataset.data = [10, 20, 30, 40, 50];
367
+ mockDatasetMeta.data = [10, 20, 30, 40, 50];
368
+ mockLineFitterInstance.minx = 0;
369
+ mockLineFitterInstance.maxx = 4;
370
+ mockLineFitterInstance.count = 5;
371
+ mockLineFitterInstance.f = jest.fn(x => {
372
+ if (x === 0) return 100;
373
+ if (x === 4) return 200;
374
+ return x * 25 + 100;
375
+ });
376
+ mockXScale.getPixelForValue = jest.fn(val => {
377
+ if (val === 0) return 100;
378
+ if (val === 4) return 400;
379
+ return val * 100 + 100;
380
+ });
381
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
382
+ if (val === 100) return 200;
383
+ if (val === 200) return 300;
384
+ return val * 1 + 100;
385
+ });
386
+
387
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
388
+
389
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
390
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0, 10);
391
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(1, 20);
392
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(2, 30);
393
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(3, 40);
394
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(4, 50);
395
+ expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
396
+ expect(drawingUtils.drawTrendline).toHaveBeenCalled();
397
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
398
+ });
399
+
400
+ test('Configuration without label property but with other properties', () => {
401
+ // Test minimal configuration similar to issue #118
402
+ mockDataset.trendlineLinear = {
403
+ colorMin: 'red',
404
+ width: 3,
405
+ lineStyle: 'dashed'
406
+ };
407
+ mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }];
408
+ mockDatasetMeta.data = [{ x: 10, y: 30 }];
409
+ mockLineFitterInstance.minx = 10;
410
+ mockLineFitterInstance.maxx = 20;
411
+ mockLineFitterInstance.count = 2;
412
+ mockLineFitterInstance.f = jest.fn(x => {
413
+ if (x === 10) return 30;
414
+ if (x === 20) return 50;
415
+ return x * 2 + 10;
416
+ });
417
+ mockXScale.getPixelForValue = jest.fn(val => {
418
+ if (val === 10) return 100;
419
+ if (val === 20) return 200;
420
+ return val * 10;
421
+ });
422
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
423
+ if (val === 30) return 200;
424
+ if (val === 50) return 300;
425
+ return val * 10;
426
+ });
427
+
428
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
429
+
430
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
431
+ expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed');
432
+ expect(drawingUtils.drawTrendline).toHaveBeenCalled();
433
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
434
+ });
435
+
436
+ test('Time scale with array of numbers and no label property (issue #118)', () => {
437
+ // Test case for time scale with array data (like lineChartTypeTime.html)
438
+ mockXScale.options.type = 'time';
439
+ mockDataset.trendlineLinear = {
440
+ lineStyle: 'dotted',
441
+ width: 2
442
+ };
443
+ mockDataset.data = [75, 64, 52, 23, 44]; // Just numbers like in the example
444
+ mockDatasetMeta.data = [75, 64, 52, 23, 44];
445
+ mockDatasetMeta.controller.chart.data.labels = [
446
+ '2025-03-01T00:00:00',
447
+ '2025-03-02T00:00:00',
448
+ '2025-03-03T00:00:00',
449
+ '2025-03-04T00:00:00',
450
+ '2025-03-05T00:00:00'
451
+ ];
452
+
453
+ const date1 = new Date('2025-03-01T00:00:00').getTime();
454
+ const date5 = new Date('2025-03-05T00:00:00').getTime();
455
+
456
+ mockLineFitterInstance.minx = date1;
457
+ mockLineFitterInstance.maxx = date5;
458
+ mockLineFitterInstance.count = 5;
459
+ mockLineFitterInstance.f = jest.fn(x => {
460
+ if (x === date1) return 75;
461
+ if (x === date5) return 44;
462
+ return 50; // approximation
463
+ });
464
+ mockXScale.getPixelForValue = jest.fn(val => {
465
+ if (val === date1) return 100;
466
+ if (val === date5) return 300;
467
+ return 200;
468
+ });
469
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
470
+ if (val === 75) return 200;
471
+ if (val === 44) return 250;
472
+ return 225;
473
+ });
474
+
475
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
476
+
477
+ expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
478
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1, 75);
479
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-02T00:00:00').getTime(), 64);
480
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-03T00:00:00').getTime(), 52);
481
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-04T00:00:00').getTime(), 23);
482
+ expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-05T00:00:00').getTime(), 44);
483
+ expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
484
+ expect(drawingUtils.drawTrendline).toHaveBeenCalled();
485
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
486
+ });
487
+ });
488
+
489
+ describe('addFitter - Exponential Trendlines', () => {
490
+ let mockCtx;
491
+ let mockDatasetMeta;
492
+ let mockDataset;
493
+ let mockXScale;
494
+ let mockYScale;
495
+ let mockExponentialFitterInstance;
496
+
497
+ beforeEach(() => {
498
+ jest.clearAllMocks();
499
+
500
+ // Mock ExponentialFitter instance
501
+ mockExponentialFitterInstance = {
502
+ add: jest.fn(),
503
+ f: jest.fn(x => 2 * Math.exp(0.5 * x)), // Example: y = 2 * e^(0.5x)
504
+ coefficient: jest.fn(() => 2),
505
+ growthRate: jest.fn(() => 0.5),
506
+ scale: jest.fn(() => 0.5),
507
+ minx: undefined,
508
+ maxx: undefined,
509
+ count: 0,
510
+ hasValidData: true
511
+ };
512
+ ExponentialFitter.mockImplementation(() => mockExponentialFitterInstance);
513
+
514
+ mockCtx = {
515
+ save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(),
516
+ measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '',
517
+ strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(),
518
+ lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(), setLineDash: jest.fn(),
519
+ };
520
+
521
+ mockDatasetMeta = {
522
+ controller: {
523
+ chart: {
524
+ scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } },
525
+ options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } },
526
+ chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 },
527
+ data: { labels: [] }
528
+ }
529
+ },
530
+ data: [{x:0, y:0}]
531
+ };
532
+
533
+ mockXScale = {
534
+ getPixelForValue: jest.fn(val => val * 10),
535
+ getValueForPixel: jest.fn(pixel => pixel / 10),
536
+ options: { type: 'linear' }
537
+ };
538
+
539
+ mockYScale = {
540
+ getPixelForValue: jest.fn(val => val * 10),
541
+ getValueForPixel: jest.fn(pixel => pixel / 10)
542
+ };
543
+
544
+ mockDataset = {
545
+ data: [ { x: 0, y: 2 }, { x: 1, y: 3.3 }, { x: 2, y: 5.4 } ], // Exponential-like data
546
+ yAxisID: 'y',
547
+ borderColor: 'blue',
548
+ borderWidth: 2,
549
+ trendlineExponential: {
550
+ colorMin: 'red',
551
+ colorMax: 'red',
552
+ width: 3,
553
+ lineStyle: 'solid',
554
+ fillColor: false,
555
+ trendoffset: 0,
556
+ projection: false,
557
+ xAxisKey: 'x',
558
+ yAxisKey: 'y',
559
+ label: {
560
+ display: true,
561
+ text: 'Exponential Trend',
562
+ color: 'black',
563
+ offset: 5,
564
+ displayValue: true,
565
+ font: { family: 'Arial', size: 12 }
566
+ }
567
+ }
568
+ };
569
+ });
570
+
571
+ test('Basic exponential trendline rendering', () => {
572
+ mockDatasetMeta.data = [{x:0, y:2}];
573
+ mockExponentialFitterInstance.minx = 0;
574
+ mockExponentialFitterInstance.maxx = 2;
575
+ mockExponentialFitterInstance.count = 3;
576
+ mockExponentialFitterInstance.f = jest.fn(x => {
577
+ if (x === 0) return 2;
578
+ if (x === 2) return 5.4;
579
+ return 2 * Math.exp(0.5 * x);
580
+ });
581
+
582
+ mockXScale.getPixelForValue = jest.fn(val => {
583
+ if (val === 0) return 50;
584
+ if (val === 2) return 250;
585
+ return val * 100 + 50;
586
+ });
587
+
588
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
589
+ if (val === 2) return 200;
590
+ if (val === 5.4) return 340;
591
+ return val * 100;
592
+ });
593
+
594
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
595
+
596
+ expect(ExponentialFitter).toHaveBeenCalledTimes(1);
597
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3);
598
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(0, 2);
599
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 3.3);
600
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 5.4);
601
+ expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({
602
+ x1: 50, y1: 200, x2: 250, y2: 340
603
+ }));
604
+ });
605
+
606
+ test('Exponential trendline with label showing coefficient and growth rate', () => {
607
+ mockDatasetMeta.data = [{x:0, y:2}];
608
+ mockExponentialFitterInstance.minx = 0;
609
+ mockExponentialFitterInstance.maxx = 2;
610
+ mockExponentialFitterInstance.count = 3;
611
+ mockExponentialFitterInstance.coefficient = jest.fn(() => 2.05);
612
+ mockExponentialFitterInstance.growthRate = jest.fn(() => 0.48);
613
+ mockExponentialFitterInstance.f = jest.fn(x => {
614
+ if (x === 0) return 2;
615
+ if (x === 2) return 5.4;
616
+ return 2.05 * Math.exp(0.48 * x);
617
+ });
618
+
619
+ mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
620
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
621
+
622
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
623
+
624
+ expect(labelUtils.addTrendlineLabel).toHaveBeenCalledWith(
625
+ mockCtx,
626
+ 'Exponential Trend (a=2.05, b=0.48)',
627
+ expect.any(Number),
628
+ expect.any(Number),
629
+ expect.any(Number),
630
+ expect.any(Number),
631
+ expect.any(Number),
632
+ 'black',
633
+ 'Arial',
634
+ 12,
635
+ 5
636
+ );
637
+ });
638
+
639
+ test('Exponential trendline checks projection configuration', () => {
640
+ mockDataset.trendlineExponential.projection = true;
641
+ mockDatasetMeta.data = [{x:0, y:2}];
642
+ mockExponentialFitterInstance.minx = 0;
643
+ mockExponentialFitterInstance.maxx = 2;
644
+ mockExponentialFitterInstance.count = 3;
645
+ mockExponentialFitterInstance.f = jest.fn(x => 2 * Math.exp(0.5 * x));
646
+
647
+ mockXScale.getValueForPixel = jest.fn(pixel => pixel / 100);
648
+ mockXScale.getPixelForValue = jest.fn(val => val * 100);
649
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
650
+ mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => pixel / 100);
651
+
652
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
653
+
654
+ // Just verify that projection mode calls the boundary functions
655
+ expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(50); // left boundary
656
+ expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(750); // right boundary
657
+ });
658
+
659
+ test('Exponential trendline with fill color', () => {
660
+ mockDataset.trendlineExponential.fillColor = 'rgba(255,0,0,0.2)';
661
+ mockDatasetMeta.data = [{x:0, y:2}];
662
+ mockExponentialFitterInstance.minx = 0;
663
+ mockExponentialFitterInstance.maxx = 2;
664
+ mockExponentialFitterInstance.count = 3;
665
+ mockExponentialFitterInstance.f = jest.fn(x => {
666
+ if (x === 0) return 2;
667
+ if (x === 2) return 5.4;
668
+ return 2 * Math.exp(0.5 * x);
669
+ });
670
+
671
+ mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
672
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
673
+
674
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
675
+
676
+ expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith(
677
+ mockCtx,
678
+ expect.any(Number), // x1
679
+ expect.any(Number), // y1
680
+ expect.any(Number), // x2
681
+ expect.any(Number), // y2
682
+ mockDatasetMeta.controller.chart.chartArea.bottom,
683
+ 'rgba(255,0,0,0.2)'
684
+ );
685
+ });
686
+
687
+ test('Exponential trendline with insufficient data points', () => {
688
+ mockDataset.data = [{ x: 0, y: 2 }]; // Only one data point
689
+ mockExponentialFitterInstance.count = 1;
690
+
691
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
692
+
693
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
694
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
695
+ });
696
+
697
+ test('Exponential trendline with trendoffset', () => {
698
+ mockDataset.trendlineExponential.trendoffset = 1;
699
+ mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 4 }];
700
+ mockDatasetMeta.data = [{x:0, y:1}];
701
+ mockExponentialFitterInstance.minx = 1;
702
+ mockExponentialFitterInstance.maxx = 2;
703
+ mockExponentialFitterInstance.count = 2;
704
+ mockExponentialFitterInstance.f = jest.fn(x => {
705
+ if (x === 1) return 2;
706
+ if (x === 2) return 4;
707
+ return Math.exp(x);
708
+ });
709
+
710
+ mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
711
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
712
+
713
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
714
+
715
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(2);
716
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 2);
717
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 4);
718
+ expect(mockExponentialFitterInstance.add).not.toHaveBeenCalledWith(0, 1);
719
+ });
720
+
721
+ test('Exponential trendline with invalid data (hasValidData = false)', () => {
722
+ mockExponentialFitterInstance.hasValidData = false;
723
+ mockExponentialFitterInstance.count = 3;
724
+
725
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
726
+
727
+ expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
728
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
729
+ });
730
+
731
+ test('Exponential trendline with time scale', () => {
732
+ mockXScale.options.type = 'time';
733
+ const date1 = new Date('2023-01-01T00:00:00.000Z');
734
+ const date2 = new Date('2023-01-02T00:00:00.000Z');
735
+ const date3 = new Date('2023-01-03T00:00:00.000Z');
736
+
737
+ mockDataset.data = [
738
+ { x: date1.toISOString(), y: 2 },
739
+ { x: date2.toISOString(), y: 4 },
740
+ { x: date3.toISOString(), y: 8 }
741
+ ];
742
+ mockDatasetMeta.data = [{ x: date1.toISOString(), y: 2 }];
743
+
744
+ const date1Ts = date1.getTime();
745
+ const date3Ts = date3.getTime();
746
+
747
+ mockExponentialFitterInstance.minx = date1Ts;
748
+ mockExponentialFitterInstance.maxx = date3Ts;
749
+ mockExponentialFitterInstance.count = 3;
750
+ mockExponentialFitterInstance.f = jest.fn(x => {
751
+ if (x === date1Ts) return 2;
752
+ if (x === date3Ts) return 8;
753
+ return 2 * Math.exp(0.693 * (x - date1Ts) / (24 * 60 * 60 * 1000));
754
+ });
755
+
756
+ mockXScale.getPixelForValue = jest.fn(val => val / 1000000);
757
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
758
+
759
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
760
+
761
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3);
762
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date1Ts, 2);
763
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 4);
764
+ expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date3Ts, 8);
765
+ });
766
+
767
+ test('Exponential trendline with minimal configuration (no label)', () => {
768
+ mockDataset.trendlineExponential = {
769
+ colorMin: 'blue',
770
+ width: 2,
771
+ lineStyle: 'dashed'
772
+ };
773
+ mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }];
774
+ mockDatasetMeta.data = [{x:0, y:1}];
775
+ mockExponentialFitterInstance.minx = 0;
776
+ mockExponentialFitterInstance.maxx = 1;
777
+ mockExponentialFitterInstance.count = 2;
778
+ mockExponentialFitterInstance.f = jest.fn(x => Math.exp(x));
779
+
780
+ mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50);
781
+ mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100);
782
+
783
+ addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
784
+
785
+ expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed');
786
+ expect(drawingUtils.drawTrendline).toHaveBeenCalled();
787
+ expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
788
+ });
789
+ });