chartjs-plugin-trendline 3.1.0 → 3.1.1
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{"use strict";class t{constructor(){this.count=0,this.sumx=0,this.sumy=0,this.sumx2=0,this.sumxy=0,this.minx=Number.MAX_VALUE,this.maxx=Number.MIN_VALUE}add(t,e){this.sumx+=t,this.sumy+=e,this.sumx2+=t*t,this.sumxy+=t*e,t<this.minx&&(this.minx=t),t>this.maxx&&(this.maxx=t),this.count
|
|
1
|
+
(()=>{"use strict";class t{constructor(){this.count=0,this.sumx=0,this.sumy=0,this.sumx2=0,this.sumxy=0,this.minx=Number.MAX_VALUE,this.maxx=Number.MIN_VALUE,this._cachedSlope=null,this._cachedIntercept=null,this._cacheValid=!1}add(t,e){this.sumx+=t,this.sumy+=e,this.sumx2+=t*t,this.sumxy+=t*e,t<this.minx&&(this.minx=t),t>this.maxx&&(this.maxx=t),this.count++,this._cacheValid=!1}slope(){return this._cacheValid||this._computeCoefficients(),this._cachedSlope}intercept(){return this._cacheValid||this._computeCoefficients(),this._cachedIntercept}f(t){return this.slope()*t+this.intercept()}fo(){return-this.intercept()/this.slope()}scale(){return this.slope()}_computeCoefficients(){const t=this.count*this.sumx2-this.sumx*this.sumx;this._cachedSlope=(this.count*this.sumxy-this.sumx*this.sumy)/t,this._cachedIntercept=(this.sumy-this._cachedSlope*this.sumx)/this.count,this._cacheValid=!0}}class e{constructor(){this.count=0,this.sumx=0,this.sumlny=0,this.sumx2=0,this.sumxlny=0,this.minx=Number.MAX_VALUE,this.maxx=Number.MIN_VALUE,this.hasValidData=!0,this.dataPoints=[],this._cachedGrowthRate=null,this._cachedCoefficient=null,this._cachedCorrelation=null,this._cacheValid=!1}add(t,e){if(e<=0)return void(this.hasValidData=!1);const i=Math.log(e);isFinite(i)?(this.sumx+=t,this.sumlny+=i,this.sumx2+=t*t,this.sumxlny+=t*i,t<this.minx&&(this.minx=t),t>this.maxx&&(this.maxx=t),this.dataPoints.push({x:t,y:e,lny:i}),this.count++,this._cacheValid=!1):this.hasValidData=!1}growthRate(){return!this.hasValidData||this.count<2?0:(this._cacheValid||this._computeCoefficients(),this._cachedGrowthRate)}coefficient(){return!this.hasValidData||this.count<2?1:(this._cacheValid||this._computeCoefficients(),this._cachedCoefficient)}f(t){if(!this.hasValidData||this.count<2)return 0;if(this._cacheValid||this._computeCoefficients(),Math.abs(this._cachedGrowthRate*t)>500)return 0;const e=this._cachedCoefficient*Math.exp(this._cachedGrowthRate*t);return isFinite(e)?e:0}correlation(){return!this.hasValidData||this.count<2?0:(this._cacheValid||this._computeCoefficients(),this._cachedCorrelation)}scale(){return this.growthRate()}_computeCoefficients(){if(!this.hasValidData||this.count<2)return this._cachedGrowthRate=0,this._cachedCoefficient=1,this._cachedCorrelation=0,void(this._cacheValid=!0);const t=this.count*this.sumx2-this.sumx*this.sumx;if(Math.abs(t)<1e-10)return this._cachedGrowthRate=0,this._cachedCoefficient=1,this._cachedCorrelation=0,void(this._cacheValid=!0);this._cachedGrowthRate=(this.count*this.sumxlny-this.sumx*this.sumlny)/t;const e=(this.sumlny-this._cachedGrowthRate*this.sumx)/this.count;this._cachedCoefficient=Math.exp(e);const i=this.sumlny/this.count;let s=0,a=0;for(const t of this.dataPoints){const n=e+this._cachedGrowthRate*t.x;s+=Math.pow(t.lny-i,2),a+=Math.pow(t.lny-n,2)}this._cachedCorrelation=0===s?1:Math.max(0,1-a/s),this._cacheValid=!0}}const i={id:"chartjs-plugin-trendline",afterDatasetsDraw:i=>{const s=i.ctx,{xScale:a,yScale:n}=(t=>{let e,i;for(const s of Object.values(t.scales))if(s.isHorizontal()?e=s:i=s,e&&i)break;return{xScale:e,yScale:i}})(i);i.data.datasets.map(((t,e)=>({dataset:t,index:e}))).filter((t=>t.dataset.trendlineLinear||t.dataset.trendlineExponential)).sort(((t,e)=>{const i=t.dataset.order??0,s=e.dataset.order??0;return 0===i&&0!==s?1:0===s&&0!==i?-1:i-s})).forEach((({dataset:l,index:o})=>{if((l.alwaysShowTrendline||i.isDatasetVisible(o))&&l.data.length>1){((i,s,a,n,l)=>{const o=a.yAxisID||"y",h=i.controller.chart.scales[o]||l,c=!!a.trendlineExponential,r=a.trendlineExponential||a.trendlineLinear||{},d=a.borderColor||"rgba(169,169,169, .6)",{colorMin:u=d,colorMax:x=d,width:f=a.borderWidth||3,lineStyle:m="solid",fillColor:y=!1}=r;let p=r.trendoffset||0;const{color:g=d,text:_=(c?"Exponential Trendline":"Trendline"),display:V=!0,displayValue:b=!0,offset:w=10,percentage:F=!1}=r&&r.label||{},{family:C="'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:M=12}=r&&r.label&&r.label.font||{},P=i.controller.chart.options,N="object"==typeof P.parsing?P.parsing:void 0,D=r?.xAxisKey||N?.xAxisKey||"x",S=r?.yAxisKey||N?.yAxisKey||"y";let L=c?new e:new t;Math.abs(p)>=a.data.length&&(p=0);let T=0;if(p>0){const t=a.data.slice(p).findIndex((t=>null!=t));T=-1!==t?p+t:a.data.length}else{const t=a.data.findIndex((t=>null!=t));T=-1!==t?t:a.data.length}let v,A,E,G,R=T<a.data.length&&"object"==typeof a.data[T];if(a.data.forEach(((t,e)=>{if(null!=t&&!(p>0&&e<T||p<0&&e>=a.data.length+p))if(["time","timeseries"].includes(n.options.type)&&R){let e=null!=t[D]?t[D]:t.t;const i=t[S];null==e||void 0===e||null==i||isNaN(i)||L.add(new Date(e).getTime(),i)}else if(R){const e=t[D],i=t[S],s=null!=e&&!isNaN(e),a=null!=i&&!isNaN(i);s&&a&&L.add(e,i)}else if(["time","timeseries"].includes(n.options.type)&&!R){const s=i.controller.chart.data.labels;if(s&&s[e]&&null!=t&&!isNaN(t)){const i=new Date(s[e]).getTime();isNaN(i)||L.add(i,t)}}else null==t||isNaN(t)||L.add(e,t)})),L.count<2)return;const I=i.controller.chart.chartArea;if(r.projection){let t=[];if(c){const e=n.getValueForPixel(I.left),i=L.f(e);t.push({x:e,y:i});const s=n.getValueForPixel(I.right),a=L.f(s);t.push({x:s,y:a})}else{const e=L.slope(),i=L.intercept();if(Math.abs(e)>1e-6){const s=h.getValueForPixel(I.top),a=(s-i)/e;t.push({x:a,y:s});const n=h.getValueForPixel(I.bottom),l=(n-i)/e;t.push({x:l,y:n})}else t.push({x:n.getValueForPixel(I.left),y:i}),t.push({x:n.getValueForPixel(I.right),y:i});const s=n.getValueForPixel(I.left),a=L.f(s);t.push({x:s,y:a});const l=n.getValueForPixel(I.right),o=L.f(l);t.push({x:l,y:o})}const e=n.getValueForPixel(I.left),i=n.getValueForPixel(I.right),s=[h.getValueForPixel(I.top),h.getValueForPixel(I.bottom)].filter((t=>isFinite(t))),a=s.length>0?Math.min(...s):-1/0,l=s.length>0?Math.max(...s):1/0;let o=t.filter((t=>isFinite(t.x)&&isFinite(t.y)&&t.x>=e&&t.x<=i&&t.y>=a&&t.y<=l));o=o.filter(((t,e,i)=>e===i.findIndex((e=>Math.abs(e.x-t.x)<1e-4&&Math.abs(e.y-t.y)<1e-4)))),o.length>=2?(o.sort(((t,e)=>t.x-e.x||t.y-e.y)),v=n.getPixelForValue(o[0].x),A=h.getPixelForValue(o[0].y),E=n.getPixelForValue(o[o.length-1].x),G=h.getPixelForValue(o[o.length-1].y)):(v=NaN,A=NaN,E=NaN,G=NaN)}else{const t=L.f(L.minx),e=L.f(L.maxx);v=n.getPixelForValue(L.minx),A=h.getPixelForValue(t),E=n.getPixelForValue(L.maxx),G=h.getPixelForValue(e)}let k=null;if(isFinite(v)&&isFinite(A)&&isFinite(E)&&isFinite(G)&&(k=function(t,e,i,s,a){let n=i-t,l=s-e,o=0,h=1;const c=[-n,n,-l,l],r=[t-a.left,a.right-t,e-a.top,a.bottom-e];for(let t=0;t<4;t++)if(0===c[t]){if(r[t]<0)return null}else{const e=r[t]/c[t];if(c[t]<0){if(e>h)return null;o=Math.max(o,e)}else{if(e<o)return null;h=Math.min(h,e)}}return o>h?null:{x1:t+o*n,y1:e+o*l,x2:t+h*n,y2:e+h*l}}(v,A,E,G,I)),k)if(v=k.x1,A=k.y1,E=k.x2,G=k.y2,Math.abs(v-E)<.5&&Math.abs(A-G)<.5);else{s.lineWidth=f,((t,e)=>{switch(e){case"dotted":t.setLineDash([2,2]);break;case"dashed":t.setLineDash([8,3]);break;case"dashdot":t.setLineDash([8,3,2,3]);break;default:t.setLineDash([])}})(s,m),(({ctx:t,x1:e,y1:i,x2:s,y2:a,colorMin:n,colorMax:l})=>{if(isFinite(e)&&isFinite(i)&&isFinite(s)&&isFinite(a)){t.beginPath(),t.moveTo(e,i),t.lineTo(s,a);try{const o=s-e,h=a-i,c=Math.sqrt(o*o+h*h);if(c<.01)console.warn("Gradient vector too small, using solid color:",{x1:e,y1:i,x2:s,y2:a,length:c}),t.strokeStyle=n;else{let o=t.createLinearGradient(e,i,s,a);o.addColorStop(0,n),o.addColorStop(1,l),t.strokeStyle=o}}catch(e){console.warn("Gradient creation failed, using solid color:",e),t.strokeStyle=n}t.stroke(),t.closePath()}else console.warn("Cannot draw trendline: coordinates contain non-finite values",{x1:e,y1:i,x2:s,y2:a})})({ctx:s,x1:v,y1:A,x2:E,y2:G,colorMin:u,colorMax:x}),y&&((t,e,i,s,a,n,l)=>{isFinite(e)&&isFinite(i)&&isFinite(s)&&isFinite(a)&&isFinite(n)?(t.beginPath(),t.moveTo(e,i),t.lineTo(s,a),t.lineTo(s,n),t.lineTo(e,n),t.lineTo(e,i),t.closePath(),t.fillStyle=l,t.fill()):console.warn("Cannot fill below trendline: coordinates contain non-finite values",{x1:e,y1:i,x2:s,y2:a,drawBottom:n})})(s,v,A,E,G,I.bottom,y);const t=Math.atan2(G-A,E-v);if(r.label&&!1!==V){let e=_;if(b)if(c){const t=L.coefficient(),i=L.growthRate();e=`${_} (a=${t.toFixed(2)}, b=${i.toFixed(2)})`}else{const t=L.slope();e=`${_} (Slope: ${F?(100*t).toFixed(2)+"%":t.toFixed(2)})`}((t,e,i,s,a,n,l,o,h,c,r)=>{t.font=`${c}px ${h}`,t.fillStyle=o;const d=t.measureText(e).width,u=(i+a)/2,x=(s+n)/2;t.save(),t.translate(u,x),t.rotate(l);const f=-d/2,m=r;t.fillText(e,f,m),t.restore()})(s,e,v,A,E,G,t,g,C,M,w)}}})(i.getDatasetMeta(o),s,l,a,n)}})),s.setLineDash([])},beforeInit:t=>{t.data.datasets.forEach((e=>{const i=e.trendlineLinear||e.trendlineExponential;if(i&&i.label){const s=i.label,a=t.legend.options.labels.generateLabels;t.legend.options.labels.generateLabels=function(t){const n=a(t),l=i.legend;return l&&!1!==l.display&&n.push({text:l.text||s+" (Trendline)",strokeStyle:l.color||e.borderColor||"rgba(169,169,169, .6)",fillStyle:l.fillStyle||"transparent",lineCap:l.lineCap||"butt",lineDash:l.lineDash||[],lineWidth:l.width||1}),n}}}))}};"undefined"!=typeof window&&window.Chart&&(window.Chart.hasOwnProperty("register")?window.Chart.register(i):window.Chart.plugins.register(i))})();
|
package/package.json
CHANGED
|
@@ -13,6 +13,10 @@ export class ExponentialFitter {
|
|
|
13
13
|
this.maxx = Number.MIN_VALUE;
|
|
14
14
|
this.hasValidData = true;
|
|
15
15
|
this.dataPoints = []; // Store data points for correlation calculation
|
|
16
|
+
this._cachedGrowthRate = null;
|
|
17
|
+
this._cachedCoefficient = null;
|
|
18
|
+
this._cachedCorrelation = null;
|
|
19
|
+
this._cacheValid = false;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -40,6 +44,7 @@ export class ExponentialFitter {
|
|
|
40
44
|
if (x > this.maxx) this.maxx = x;
|
|
41
45
|
this.dataPoints.push({x, y, lny}); // Store actual data points
|
|
42
46
|
this.count++;
|
|
47
|
+
this._cacheValid = false;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -48,9 +53,10 @@ export class ExponentialFitter {
|
|
|
48
53
|
*/
|
|
49
54
|
growthRate() {
|
|
50
55
|
if (!this.hasValidData || this.count < 2) return 0;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
if (!this._cacheValid) {
|
|
57
|
+
this._computeCoefficients();
|
|
58
|
+
}
|
|
59
|
+
return this._cachedGrowthRate;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
/**
|
|
@@ -59,8 +65,10 @@ export class ExponentialFitter {
|
|
|
59
65
|
*/
|
|
60
66
|
coefficient() {
|
|
61
67
|
if (!this.hasValidData || this.count < 2) return 1;
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
if (!this._cacheValid) {
|
|
69
|
+
this._computeCoefficients();
|
|
70
|
+
}
|
|
71
|
+
return this._cachedCoefficient;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -70,13 +78,14 @@ export class ExponentialFitter {
|
|
|
70
78
|
*/
|
|
71
79
|
f(x) {
|
|
72
80
|
if (!this.hasValidData || this.count < 2) return 0;
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
if (!this._cacheValid) {
|
|
82
|
+
this._computeCoefficients();
|
|
83
|
+
}
|
|
75
84
|
|
|
76
85
|
// Check for potential overflow before calculation
|
|
77
|
-
if (Math.abs(
|
|
86
|
+
if (Math.abs(this._cachedGrowthRate * x) > 500) return 0; // Safer limit to prevent overflow
|
|
78
87
|
|
|
79
|
-
const result =
|
|
88
|
+
const result = this._cachedCoefficient * Math.exp(this._cachedGrowthRate * x);
|
|
80
89
|
return isFinite(result) ? result : 0;
|
|
81
90
|
}
|
|
82
91
|
|
|
@@ -86,22 +95,10 @@ export class ExponentialFitter {
|
|
|
86
95
|
*/
|
|
87
96
|
correlation() {
|
|
88
97
|
if (!this.hasValidData || this.count < 2) return 0;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const lnA = Math.log(this.coefficient());
|
|
92
|
-
const b = this.growthRate();
|
|
93
|
-
|
|
94
|
-
let ssTotal = 0;
|
|
95
|
-
let ssRes = 0;
|
|
96
|
-
|
|
97
|
-
for (const point of this.dataPoints) {
|
|
98
|
-
const predictedLnY = lnA + b * point.x;
|
|
99
|
-
ssTotal += Math.pow(point.lny - meanLnY, 2);
|
|
100
|
-
ssRes += Math.pow(point.lny - predictedLnY, 2);
|
|
98
|
+
if (!this._cacheValid) {
|
|
99
|
+
this._computeCoefficients();
|
|
101
100
|
}
|
|
102
|
-
|
|
103
|
-
if (ssTotal === 0) return 1;
|
|
104
|
-
return Math.max(0, 1 - (ssRes / ssTotal));
|
|
101
|
+
return this._cachedCorrelation;
|
|
105
102
|
}
|
|
106
103
|
|
|
107
104
|
/**
|
|
@@ -111,4 +108,40 @@ export class ExponentialFitter {
|
|
|
111
108
|
scale() {
|
|
112
109
|
return this.growthRate();
|
|
113
110
|
}
|
|
111
|
+
|
|
112
|
+
_computeCoefficients() {
|
|
113
|
+
if (!this.hasValidData || this.count < 2) {
|
|
114
|
+
this._cachedGrowthRate = 0;
|
|
115
|
+
this._cachedCoefficient = 1;
|
|
116
|
+
this._cachedCorrelation = 0;
|
|
117
|
+
this._cacheValid = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
122
|
+
if (Math.abs(denominator) < 1e-10) {
|
|
123
|
+
this._cachedGrowthRate = 0;
|
|
124
|
+
this._cachedCoefficient = 1;
|
|
125
|
+
this._cachedCorrelation = 0;
|
|
126
|
+
this._cacheValid = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this._cachedGrowthRate = (this.count * this.sumxlny - this.sumx * this.sumlny) / denominator;
|
|
131
|
+
const lnA = (this.sumlny - this._cachedGrowthRate * this.sumx) / this.count;
|
|
132
|
+
this._cachedCoefficient = Math.exp(lnA);
|
|
133
|
+
|
|
134
|
+
const meanLnY = this.sumlny / this.count;
|
|
135
|
+
let ssTotal = 0;
|
|
136
|
+
let ssRes = 0;
|
|
137
|
+
|
|
138
|
+
for (const point of this.dataPoints) {
|
|
139
|
+
const predictedLnY = lnA + this._cachedGrowthRate * point.x;
|
|
140
|
+
ssTotal += Math.pow(point.lny - meanLnY, 2);
|
|
141
|
+
ssRes += Math.pow(point.lny - predictedLnY, 2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this._cachedCorrelation = ssTotal === 0 ? 1 : Math.max(0, 1 - (ssRes / ssTotal));
|
|
145
|
+
this._cacheValid = true;
|
|
146
|
+
}
|
|
114
147
|
}
|
|
@@ -248,4 +248,116 @@ describe('ExponentialFitter', () => {
|
|
|
248
248
|
expect(smallerResult).toBeLessThan(Infinity);
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
|
+
|
|
252
|
+
describe('caching behavior', () => {
|
|
253
|
+
test('should cache coefficient, growth rate, and correlation calculations', () => {
|
|
254
|
+
const fitter = new ExponentialFitter();
|
|
255
|
+
fitter.add(0, 1);
|
|
256
|
+
fitter.add(1, 2);
|
|
257
|
+
fitter.add(2, 4);
|
|
258
|
+
|
|
259
|
+
// First calls should compute and cache
|
|
260
|
+
const growthRate1 = fitter.growthRate();
|
|
261
|
+
const coefficient1 = fitter.coefficient();
|
|
262
|
+
const correlation1 = fitter.correlation();
|
|
263
|
+
|
|
264
|
+
// Subsequent calls should return cached values
|
|
265
|
+
const growthRate2 = fitter.growthRate();
|
|
266
|
+
const coefficient2 = fitter.coefficient();
|
|
267
|
+
const correlation2 = fitter.correlation();
|
|
268
|
+
|
|
269
|
+
expect(growthRate1).toBe(growthRate2);
|
|
270
|
+
expect(coefficient1).toBe(coefficient2);
|
|
271
|
+
expect(correlation1).toBe(correlation2);
|
|
272
|
+
|
|
273
|
+
// Check that cache is populated
|
|
274
|
+
expect(fitter._cachedGrowthRate).toBeCloseTo(Math.log(2), 3);
|
|
275
|
+
expect(fitter._cachedCoefficient).toBeCloseTo(1, 3);
|
|
276
|
+
expect(fitter._cachedCorrelation).toBeGreaterThan(0.99);
|
|
277
|
+
expect(fitter._cacheValid).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('should invalidate cache when new data is added', () => {
|
|
281
|
+
const fitter = new ExponentialFitter();
|
|
282
|
+
fitter.add(0, 1);
|
|
283
|
+
fitter.add(1, 2);
|
|
284
|
+
|
|
285
|
+
// Cache should be valid after first calculation
|
|
286
|
+
fitter.growthRate();
|
|
287
|
+
expect(fitter._cacheValid).toBe(true);
|
|
288
|
+
|
|
289
|
+
// Adding new data should invalidate cache
|
|
290
|
+
fitter.add(2, 8); // This changes the exponential curve
|
|
291
|
+
expect(fitter._cacheValid).toBe(false);
|
|
292
|
+
|
|
293
|
+
// New calculation should work with updated data
|
|
294
|
+
const newGrowthRate = fitter.growthRate();
|
|
295
|
+
expect(newGrowthRate).toBeGreaterThan(Math.log(2)); // Should be higher with [0,1], [1,2], [2,8]
|
|
296
|
+
expect(fitter._cacheValid).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('should handle multiple method calls efficiently with caching', () => {
|
|
300
|
+
const fitter = new ExponentialFitter();
|
|
301
|
+
fitter.add(0, 2);
|
|
302
|
+
fitter.add(1, 4);
|
|
303
|
+
fitter.add(2, 8); // y = 2 * 2^x
|
|
304
|
+
|
|
305
|
+
// Simulate multiple calls like in trendline.js
|
|
306
|
+
const coefficient1 = fitter.coefficient();
|
|
307
|
+
const growthRate1 = fitter.growthRate();
|
|
308
|
+
const f1 = fitter.f(3); // This calls both coefficient() and growthRate()
|
|
309
|
+
const coefficient2 = fitter.coefficient(); // Another direct call
|
|
310
|
+
const correlation1 = fitter.correlation();
|
|
311
|
+
|
|
312
|
+
expect(coefficient1).toBeCloseTo(2, 3);
|
|
313
|
+
expect(coefficient2).toBeCloseTo(2, 3);
|
|
314
|
+
expect(growthRate1).toBeCloseTo(Math.log(2), 3);
|
|
315
|
+
expect(f1).toBeCloseTo(16, 3); // 2 * 2^3 = 16
|
|
316
|
+
expect(correlation1).toBeGreaterThan(0.99);
|
|
317
|
+
|
|
318
|
+
// All should use cached values
|
|
319
|
+
expect(fitter._cachedCoefficient).toBeCloseTo(2, 3);
|
|
320
|
+
expect(fitter._cachedGrowthRate).toBeCloseTo(Math.log(2), 3);
|
|
321
|
+
expect(fitter._cachedCorrelation).toBeGreaterThan(0.99);
|
|
322
|
+
expect(fitter._cacheValid).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('should initialize cache properties correctly', () => {
|
|
326
|
+
const fitter = new ExponentialFitter();
|
|
327
|
+
expect(fitter._cachedGrowthRate).toBe(null);
|
|
328
|
+
expect(fitter._cachedCoefficient).toBe(null);
|
|
329
|
+
expect(fitter._cachedCorrelation).toBe(null);
|
|
330
|
+
expect(fitter._cacheValid).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('should handle caching with invalid data correctly', () => {
|
|
334
|
+
const fitter = new ExponentialFitter();
|
|
335
|
+
fitter.add(0, -1); // Invalid data
|
|
336
|
+
|
|
337
|
+
// Should return default values without caching (early return)
|
|
338
|
+
const growthRate = fitter.growthRate();
|
|
339
|
+
const coefficient = fitter.coefficient();
|
|
340
|
+
const correlation = fitter.correlation();
|
|
341
|
+
|
|
342
|
+
expect(growthRate).toBe(0);
|
|
343
|
+
expect(coefficient).toBe(1);
|
|
344
|
+
expect(correlation).toBe(0);
|
|
345
|
+
expect(fitter._cacheValid).toBe(false); // Cache not set for invalid data
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('should handle caching with insufficient data correctly', () => {
|
|
349
|
+
const fitter = new ExponentialFitter();
|
|
350
|
+
fitter.add(0, 1); // Only one point
|
|
351
|
+
|
|
352
|
+
// Should return default values without caching (early return)
|
|
353
|
+
const growthRate = fitter.growthRate();
|
|
354
|
+
const coefficient = fitter.coefficient();
|
|
355
|
+
const correlation = fitter.correlation();
|
|
356
|
+
|
|
357
|
+
expect(growthRate).toBe(0);
|
|
358
|
+
expect(coefficient).toBe(1);
|
|
359
|
+
expect(correlation).toBe(0);
|
|
360
|
+
expect(fitter._cacheValid).toBe(false); // Cache not set for insufficient data
|
|
361
|
+
});
|
|
362
|
+
});
|
|
251
363
|
});
|
package/src/utils/lineFitter.js
CHANGED
|
@@ -10,6 +10,9 @@ export class LineFitter {
|
|
|
10
10
|
this.sumxy = 0;
|
|
11
11
|
this.minx = Number.MAX_VALUE;
|
|
12
12
|
this.maxx = Number.MIN_VALUE;
|
|
13
|
+
this._cachedSlope = null;
|
|
14
|
+
this._cachedIntercept = null;
|
|
15
|
+
this._cacheValid = false;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -25,6 +28,7 @@ export class LineFitter {
|
|
|
25
28
|
if (x < this.minx) this.minx = x;
|
|
26
29
|
if (x > this.maxx) this.maxx = x;
|
|
27
30
|
this.count++;
|
|
31
|
+
this._cacheValid = false;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -32,8 +36,10 @@ export class LineFitter {
|
|
|
32
36
|
* @returns {number} - The slope of the line.
|
|
33
37
|
*/
|
|
34
38
|
slope() {
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
if (!this._cacheValid) {
|
|
40
|
+
this._computeCoefficients();
|
|
41
|
+
}
|
|
42
|
+
return this._cachedSlope;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
@@ -41,7 +47,10 @@ export class LineFitter {
|
|
|
41
47
|
* @returns {number} - The y-intercept of the line.
|
|
42
48
|
*/
|
|
43
49
|
intercept() {
|
|
44
|
-
|
|
50
|
+
if (!this._cacheValid) {
|
|
51
|
+
this._computeCoefficients();
|
|
52
|
+
}
|
|
53
|
+
return this._cachedIntercept;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
@@ -68,4 +77,11 @@ export class LineFitter {
|
|
|
68
77
|
scale() {
|
|
69
78
|
return this.slope();
|
|
70
79
|
}
|
|
80
|
+
|
|
81
|
+
_computeCoefficients() {
|
|
82
|
+
const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
|
|
83
|
+
this._cachedSlope = (this.count * this.sumxy - this.sumx * this.sumy) / denominator;
|
|
84
|
+
this._cachedIntercept = (this.sumy - this._cachedSlope * this.sumx) / this.count;
|
|
85
|
+
this._cacheValid = true;
|
|
86
|
+
}
|
|
71
87
|
}
|
|
@@ -262,4 +262,79 @@ describe('LineFitter', () => {
|
|
|
262
262
|
expect(fitter3.scale()).toBeNaN();
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe('caching behavior', () => {
|
|
267
|
+
test('should cache slope and intercept calculations', () => {
|
|
268
|
+
const fitter = new LineFitter();
|
|
269
|
+
fitter.add(1, 1);
|
|
270
|
+
fitter.add(2, 2);
|
|
271
|
+
|
|
272
|
+
// First call should compute and cache
|
|
273
|
+
const slope1 = fitter.slope();
|
|
274
|
+
const intercept1 = fitter.intercept();
|
|
275
|
+
|
|
276
|
+
// Subsequent calls should return cached values
|
|
277
|
+
const slope2 = fitter.slope();
|
|
278
|
+
const intercept2 = fitter.intercept();
|
|
279
|
+
|
|
280
|
+
expect(slope1).toBe(slope2);
|
|
281
|
+
expect(intercept1).toBe(intercept2);
|
|
282
|
+
expect(slope1).toBe(1);
|
|
283
|
+
expect(intercept1).toBe(0);
|
|
284
|
+
|
|
285
|
+
// Check that cache is populated
|
|
286
|
+
expect(fitter._cachedSlope).toBe(1);
|
|
287
|
+
expect(fitter._cachedIntercept).toBe(0);
|
|
288
|
+
expect(fitter._cacheValid).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('should invalidate cache when new data is added', () => {
|
|
292
|
+
const fitter = new LineFitter();
|
|
293
|
+
fitter.add(1, 1);
|
|
294
|
+
fitter.add(2, 2);
|
|
295
|
+
|
|
296
|
+
// Cache should be valid after first calculation
|
|
297
|
+
fitter.slope();
|
|
298
|
+
expect(fitter._cacheValid).toBe(true);
|
|
299
|
+
|
|
300
|
+
// Adding new data should invalidate cache
|
|
301
|
+
fitter.add(3, 4);
|
|
302
|
+
expect(fitter._cacheValid).toBe(false);
|
|
303
|
+
|
|
304
|
+
// New calculation should work with updated data
|
|
305
|
+
const newSlope = fitter.slope();
|
|
306
|
+
expect(newSlope).toBeCloseTo(1.5, 5); // New slope with data [1,1], [2,2], [3,4]
|
|
307
|
+
expect(fitter._cacheValid).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('should handle multiple method calls efficiently with caching', () => {
|
|
311
|
+
const fitter = new LineFitter();
|
|
312
|
+
fitter.add(0, 1);
|
|
313
|
+
fitter.add(1, 3);
|
|
314
|
+
fitter.add(2, 5); // y = 2x + 1
|
|
315
|
+
|
|
316
|
+
// Simulate multiple calls like in trendline.js
|
|
317
|
+
const slope1 = fitter.slope();
|
|
318
|
+
const intercept1 = fitter.intercept(); // This used to call slope() again
|
|
319
|
+
const f1 = fitter.f(3); // This calls both slope() and intercept()
|
|
320
|
+
const slope2 = fitter.slope(); // Another direct call
|
|
321
|
+
|
|
322
|
+
expect(slope1).toBe(2);
|
|
323
|
+
expect(slope2).toBe(2);
|
|
324
|
+
expect(intercept1).toBe(1);
|
|
325
|
+
expect(f1).toBe(7); // 2*3 + 1 = 7
|
|
326
|
+
|
|
327
|
+
// All should use cached values
|
|
328
|
+
expect(fitter._cachedSlope).toBe(2);
|
|
329
|
+
expect(fitter._cachedIntercept).toBe(1);
|
|
330
|
+
expect(fitter._cacheValid).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('should initialize cache properties correctly', () => {
|
|
334
|
+
const fitter = new LineFitter();
|
|
335
|
+
expect(fitter._cachedSlope).toBe(null);
|
|
336
|
+
expect(fitter._cachedIntercept).toBe(null);
|
|
337
|
+
expect(fitter._cacheValid).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
265
340
|
});
|