chartjs-plugin-trendline 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # chartjs-plugin-trendline
2
2
 
3
- This plugin draws an linear trendline in your Chart.
3
+ This plugin draws linear and exponential trendlines in your Chart.
4
4
  It has been tested with Chart.js version 4.4.9.
5
5
 
6
6
  ## 📊 [View Live Examples](https://makanz.github.io/chartjs-plugin-trendline/)
@@ -35,6 +35,10 @@ ChartJS.plugins.register(chartTrendline);
35
35
 
36
36
  To configure the trendline plugin you simply add a new config options to your dataset in your chart config.
37
37
 
38
+ ### Linear Trendlines
39
+
40
+ For linear trendlines (straight lines), use the `trendlineLinear` configuration:
41
+
38
42
  ```javascript
39
43
  {
40
44
  trendlineLinear: {
@@ -51,7 +55,7 @@ To configure the trendline plugin you simply add a new config options to your da
51
55
  color: Color,
52
56
  text: string,
53
57
  display: boolean,
54
- displayValue: boolean,
58
+ displayValue: boolean, // shows slope value
55
59
  offset: number,
56
60
  percentage: boolean,
57
61
  font: {
@@ -72,12 +76,65 @@ To configure the trendline plugin you simply add a new config options to your da
72
76
  }
73
77
  ```
74
78
 
79
+ ### Exponential Trendlines
80
+
81
+ For exponential trendlines (curves of the form y = a × e^(b×x)), use the `trendlineExponential` configuration:
82
+
83
+ ```javascript
84
+ {
85
+ trendlineExponential: {
86
+ colorMin: Color,
87
+ colorMax: Color,
88
+ lineStyle: string, // "dotted" | "solid" | "dashed" | "dashdot"
89
+ width: number,
90
+ xAxisKey: string, // optional
91
+ yAxisKey: string, // optional
92
+ projection: boolean, // optional
93
+ trendoffset: number, // optional, if > 0 skips first n elements, if < 0 uses last n elements
94
+ // optional
95
+ label: {
96
+ color: Color,
97
+ text: string,
98
+ display: boolean,
99
+ displayValue: boolean, // shows exponential parameters (a, b)
100
+ offset: number,
101
+ font: {
102
+ family: string,
103
+ size: number,
104
+ }
105
+ },
106
+ // optional
107
+ legend: {
108
+ text: string,
109
+ strokeStyle: Color,
110
+ fillStyle: Color,
111
+ lineCap: string,
112
+ lineDash: number[],
113
+ lineWidth: number,
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ **Note:** Exponential trendlines work best with positive y-values. The equation fitted is y = a × e^(b×x), where:
120
+ - `a` is the coefficient (scaling factor)
121
+ - `b` is the growth rate (positive for growth, negative for decay)
122
+
123
+ ## Examples
124
+
125
+ - [Linear Trendline Example](./example/lineChart.html)
126
+ - [Exponential Trendline Example](./example/exponentialChart.html)
127
+ - [Bar Chart with Trendline](./example/barChart.html)
128
+ - [Scatter Chart with Trendline](./example/scatterChart.html)
129
+
75
130
  ## Supported chart types
76
131
 
77
132
  - bar
78
133
  - line
79
134
  - scatter
80
135
 
136
+ Both linear and exponential trendlines are supported for all chart types.
137
+
81
138
  ## Contributing
82
139
 
83
140
  Pull requests and issues are always welcome.
package/changelog.md CHANGED
@@ -1,3 +1,16 @@
1
+ # Changelog 3.1.0
2
+
3
+ ### New Features
4
+ - Added exponential trendline support with `trendlineExponential` configuration
5
+ - Exponential trendlines fit curves of the form y = a × e^(b×x)
6
+ - All existing styling options (colors, width, lineStyle, projection, etc.) work with exponential trendlines
7
+ - Added comprehensive test coverage for exponential functionality
8
+ - Added exponential trendline example (exponentialChart.html)
9
+
10
+ ### Improvements
11
+ - Updated README with exponential trendline documentation
12
+ - Enhanced package description to mention exponential support
13
+
1
14
  # Changelog 3.0.0
2
15
 
3
16
  ### Breaking Changes
@@ -1 +1 @@
1
- (()=>{"use strict";class e{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(e,t){this.sumx+=e,this.sumy+=t,this.sumx2+=e*e,this.sumxy+=e*t,e<this.minx&&(this.minx=e),e>this.maxx&&(this.maxx=e),this.count++}slope(){const e=this.count*this.sumx2-this.sumx*this.sumx;return(this.count*this.sumxy-this.sumx*this.sumy)/e}intercept(){return(this.sumy-this.slope()*this.sumx)/this.count}f(e){return this.slope()*e+this.intercept()}fo(){return-this.intercept()/this.slope()}scale(){return this.slope()}}const t={id:"chartjs-plugin-trendline",afterDatasetsDraw:t=>{const i=t.ctx,{xScale:n,yScale:l}=(e=>{let t,i;for(const n of Object.values(e.scales))if(n.isHorizontal()?t=n:i=n,t&&i)break;return{xScale:t,yScale:i}})(t);t.data.datasets.map(((e,t)=>({dataset:e,index:t}))).filter((e=>e.dataset.trendlineLinear)).sort(((e,t)=>{const i=e.dataset.order??0,n=t.dataset.order??0;return 0===i&&0!==n?1:0===n&&0!==i?-1:i-n})).forEach((({dataset:s,index:a})=>{if((s.alwaysShowTrendline||t.isDatasetVisible(a))&&s.data.length>1){((t,i,n,l,s)=>{const a=n.yAxisID||"y",o=t.controller.chart.scales[a]||s,r=n.borderColor||"rgba(169,169,169, .6)",{colorMin:d=r,colorMax:c=r,width:x=n.borderWidth||3,lineStyle:h="solid",fillColor:u=!1}=n.trendlineLinear||{};let f=(n.trendlineLinear||{}).trendoffset||0;const{color:y=r,text:g="Trendline",display:m=!0,displayValue:p=!0,offset:b=10,percentage:F=!1}=n.trendlineLinear&&n.trendlineLinear.label||{},{family:w="'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:L=12}=n.trendlineLinear&&n.trendlineLinear.label&&n.trendlineLinear.label.font||{},N=t.controller.chart.options,P="object"==typeof N.parsing?N.parsing:void 0,V=n.trendlineLinear?.xAxisKey||P?.xAxisKey||"x",M=n.trendlineLinear?.yAxisKey||P?.yAxisKey||"y";let S=new e;Math.abs(f)>=n.data.length&&(f=0);let D=0;if(f>0){const e=n.data.slice(f).findIndex((e=>null!=e));D=-1!==e?f+e:n.data.length}else{const e=n.data.findIndex((e=>null!=e));D=-1!==e?e:n.data.length}let T,C,v,A,k=D<n.data.length&&"object"==typeof n.data[D];if(n.data.forEach(((e,i)=>{if(null!=e&&!(f>0&&i<D||f<0&&i>=n.data.length+f))if(["time","timeseries"].includes(l.options.type)&&k){let t=null!=e[V]?e[V]:e.t;const i=e[M];null==t||void 0===t||null==i||isNaN(i)||S.add(new Date(t).getTime(),i)}else if(k){const t=e[V],i=e[M],n=null!=t&&!isNaN(t),l=null!=i&&!isNaN(i);n&&l&&S.add(t,i)}else if(["time","timeseries"].includes(l.options.type)&&!k){const n=t.controller.chart.data.labels;if(n&&n[i]&&null!=e&&!isNaN(e)){const t=new Date(n[i]).getTime();isNaN(t)||S.add(t,e)}}else null==e||isNaN(e)||S.add(i,e)})),S.count<2)return;const I=t.controller.chart.chartArea;if(n.trendlineLinear.projection){const e=S.slope(),t=S.intercept();let i=[];if(Math.abs(e)>1e-6){const n=o.getValueForPixel(I.top),l=(n-t)/e;i.push({x:l,y:n});const s=o.getValueForPixel(I.bottom),a=(s-t)/e;i.push({x:a,y:s})}else i.push({x:l.getValueForPixel(I.left),y:t}),i.push({x:l.getValueForPixel(I.right),y:t});const n=l.getValueForPixel(I.left),s=S.f(n);i.push({x:n,y:s});const a=l.getValueForPixel(I.right),r=S.f(a);i.push({x:a,y:r});const d=l.getValueForPixel(I.left),c=l.getValueForPixel(I.right),x=[o.getValueForPixel(I.top),o.getValueForPixel(I.bottom)].filter((e=>isFinite(e))),h=x.length>0?Math.min(...x):-1/0,u=x.length>0?Math.max(...x):1/0;let f=i.filter((e=>isFinite(e.x)&&isFinite(e.y)&&e.x>=d&&e.x<=c&&e.y>=h&&e.y<=u));f=f.filter(((e,t,i)=>t===i.findIndex((t=>Math.abs(t.x-e.x)<1e-4&&Math.abs(t.y-e.y)<1e-4)))),f.length>=2?(f.sort(((e,t)=>e.x-t.x||e.y-t.y)),T=l.getPixelForValue(f[0].x),C=o.getPixelForValue(f[0].y),v=l.getPixelForValue(f[f.length-1].x),A=o.getPixelForValue(f[f.length-1].y)):(T=NaN,C=NaN,v=NaN,A=NaN)}else{const e=S.f(S.minx),t=S.f(S.maxx);T=l.getPixelForValue(S.minx),C=o.getPixelForValue(e),v=l.getPixelForValue(S.maxx),A=o.getPixelForValue(t)}let j=null;if(isFinite(T)&&isFinite(C)&&isFinite(v)&&isFinite(A)&&(j=function(e,t,i,n,l){let s=i-e,a=n-t,o=0,r=1;const d=[-s,s,-a,a],c=[e-l.left,l.right-e,t-l.top,l.bottom-t];for(let e=0;e<4;e++)if(0===d[e]){if(c[e]<0)return null}else{const t=c[e]/d[e];if(d[e]<0){if(t>r)return null;o=Math.max(o,t)}else{if(t<o)return null;r=Math.min(r,t)}}return o>r?null:{x1:e+o*s,y1:t+o*a,x2:e+r*s,y2:t+r*a}}(T,C,v,A,I)),j)if(T=j.x1,C=j.y1,v=j.x2,A=j.y2,Math.abs(T-v)<.5&&Math.abs(C-A)<.5);else{i.lineWidth=x,((e,t)=>{switch(t){case"dotted":e.setLineDash([2,2]);break;case"dashed":e.setLineDash([8,3]);break;case"dashdot":e.setLineDash([8,3,2,3]);break;default:e.setLineDash([])}})(i,h),(({ctx:e,x1:t,y1:i,x2:n,y2:l,colorMin:s,colorMax:a})=>{if(isFinite(t)&&isFinite(i)&&isFinite(n)&&isFinite(l)){e.beginPath(),e.moveTo(t,i),e.lineTo(n,l);try{const o=n-t,r=l-i,d=Math.sqrt(o*o+r*r);if(d<.01)console.warn("Gradient vector too small, using solid color:",{x1:t,y1:i,x2:n,y2:l,length:d}),e.strokeStyle=s;else{let o=e.createLinearGradient(t,i,n,l);o.addColorStop(0,s),o.addColorStop(1,a),e.strokeStyle=o}}catch(t){console.warn("Gradient creation failed, using solid color:",t),e.strokeStyle=s}e.stroke(),e.closePath()}else console.warn("Cannot draw trendline: coordinates contain non-finite values",{x1:t,y1:i,x2:n,y2:l})})({ctx:i,x1:T,y1:C,x2:v,y2:A,colorMin:d,colorMax:c}),u&&((e,t,i,n,l,s,a)=>{isFinite(t)&&isFinite(i)&&isFinite(n)&&isFinite(l)&&isFinite(s)?(e.beginPath(),e.moveTo(t,i),e.lineTo(n,l),e.lineTo(n,s),e.lineTo(t,s),e.lineTo(t,i),e.closePath(),e.fillStyle=a,e.fill()):console.warn("Cannot fill below trendline: coordinates contain non-finite values",{x1:t,y1:i,x2:n,y2:l,drawBottom:s})})(i,T,C,v,A,I.bottom,u);const e=Math.atan2(A-C,v-T),t=S.slope();n.trendlineLinear.label&&!1!==m&&((e,t,i,n,l,s,a,o,r,d,c)=>{e.font=`${d}px ${r}`,e.fillStyle=o;const x=e.measureText(t).width,h=(i+l)/2,u=(n+s)/2;e.save(),e.translate(h,u),e.rotate(a);const f=-x/2,y=c;e.fillText(t,f,y),e.restore()})(i,p?`${g} (Slope: ${F?(100*t).toFixed(2)+"%":t.toFixed(2)})`:g,T,C,v,A,e,y,w,L,b)}})(t.getDatasetMeta(a),i,s,n,l)}})),i.setLineDash([])},beforeInit:e=>{e.data.datasets.forEach((t=>{if(t.trendlineLinear&&t.trendlineLinear.label){const i=t.trendlineLinear.label,n=e.legend.options.labels.generateLabels;e.legend.options.labels.generateLabels=function(e){const l=n(e),s=t.trendlineLinear.legend;return s&&!1!==s.display&&l.push({text:s.text||i+" (Trendline)",strokeStyle:s.color||t.borderColor||"rgba(169,169,169, .6)",fillStyle:s.fillStyle||"transparent",lineCap:s.lineCap||"butt",lineDash:s.lineDash||[],lineWidth:s.width||1}),l}}}))}};"undefined"!=typeof window&&window.Chart&&(window.Chart.hasOwnProperty("register")?window.Chart.register(t):window.Chart.plugins.register(t))})();
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++}slope(){const t=this.count*this.sumx2-this.sumx*this.sumx;return(this.count*this.sumxy-this.sumx*this.sumy)/t}intercept(){return(this.sumy-this.slope()*this.sumx)/this.count}f(t){return this.slope()*t+this.intercept()}fo(){return-this.intercept()/this.slope()}scale(){return this.slope()}}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=[]}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.hasValidData=!1}growthRate(){if(!this.hasValidData||this.count<2)return 0;const t=this.count*this.sumx2-this.sumx*this.sumx;return Math.abs(t)<1e-10?0:(this.count*this.sumxlny-this.sumx*this.sumlny)/t}coefficient(){if(!this.hasValidData||this.count<2)return 1;const t=(this.sumlny-this.growthRate()*this.sumx)/this.count;return Math.exp(t)}f(t){if(!this.hasValidData||this.count<2)return 0;const e=this.coefficient(),i=this.growthRate();if(Math.abs(i*t)>500)return 0;const s=e*Math.exp(i*t);return isFinite(s)?s:0}correlation(){if(!this.hasValidData||this.count<2)return 0;const t=this.sumlny/this.count,e=Math.log(this.coefficient()),i=this.growthRate();let s=0,n=0;for(const a of this.dataPoints){const l=e+i*a.x;s+=Math.pow(a.lny-t,2),n+=Math.pow(a.lny-l,2)}return 0===s?1:Math.max(0,1-n/s)}scale(){return this.growthRate()}}const i={id:"chartjs-plugin-trendline",afterDatasetsDraw:i=>{const s=i.ctx,{xScale:n,yScale:a}=(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,n,a,l)=>{const o=n.yAxisID||"y",r=i.controller.chart.scales[o]||l,h=!!n.trendlineExponential,c=n.trendlineExponential||n.trendlineLinear||{},u=n.borderColor||"rgba(169,169,169, .6)",{colorMin:x=u,colorMax:d=u,width:f=n.borderWidth||3,lineStyle:y="solid",fillColor:m=!1}=c;let g=c.trendoffset||0;const{color:p=u,text:b=(h?"Exponential Trendline":"Trendline"),display:F=!0,displayValue:w=!0,offset:V=10,percentage:M=!1}=c&&c.label||{},{family:P="'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:N=12}=c&&c.label&&c.label.font||{},D=i.controller.chart.options,S="object"==typeof D.parsing?D.parsing:void 0,L=c?.xAxisKey||S?.xAxisKey||"x",T=c?.yAxisKey||S?.yAxisKey||"y";let A=h?new e:new t;Math.abs(g)>=n.data.length&&(g=0);let C=0;if(g>0){const t=n.data.slice(g).findIndex((t=>null!=t));C=-1!==t?g+t:n.data.length}else{const t=n.data.findIndex((t=>null!=t));C=-1!==t?t:n.data.length}let v,E,k,I,$=C<n.data.length&&"object"==typeof n.data[C];if(n.data.forEach(((t,e)=>{if(null!=t&&!(g>0&&e<C||g<0&&e>=n.data.length+g))if(["time","timeseries"].includes(a.options.type)&&$){let e=null!=t[L]?t[L]:t.t;const i=t[T];null==e||void 0===e||null==i||isNaN(i)||A.add(new Date(e).getTime(),i)}else if($){const e=t[L],i=t[T],s=null!=e&&!isNaN(e),n=null!=i&&!isNaN(i);s&&n&&A.add(e,i)}else if(["time","timeseries"].includes(a.options.type)&&!$){const s=i.controller.chart.data.labels;if(s&&s[e]&&null!=t&&!isNaN(t)){const i=new Date(s[e]).getTime();isNaN(i)||A.add(i,t)}}else null==t||isNaN(t)||A.add(e,t)})),A.count<2)return;const R=i.controller.chart.chartArea;if(c.projection){let t=[];if(h){const e=a.getValueForPixel(R.left),i=A.f(e);t.push({x:e,y:i});const s=a.getValueForPixel(R.right),n=A.f(s);t.push({x:s,y:n})}else{const e=A.slope(),i=A.intercept();if(Math.abs(e)>1e-6){const s=r.getValueForPixel(R.top),n=(s-i)/e;t.push({x:n,y:s});const a=r.getValueForPixel(R.bottom),l=(a-i)/e;t.push({x:l,y:a})}else t.push({x:a.getValueForPixel(R.left),y:i}),t.push({x:a.getValueForPixel(R.right),y:i});const s=a.getValueForPixel(R.left),n=A.f(s);t.push({x:s,y:n});const l=a.getValueForPixel(R.right),o=A.f(l);t.push({x:l,y:o})}const e=a.getValueForPixel(R.left),i=a.getValueForPixel(R.right),s=[r.getValueForPixel(R.top),r.getValueForPixel(R.bottom)].filter((t=>isFinite(t))),n=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>=n&&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=a.getPixelForValue(o[0].x),E=r.getPixelForValue(o[0].y),k=a.getPixelForValue(o[o.length-1].x),I=r.getPixelForValue(o[o.length-1].y)):(v=NaN,E=NaN,k=NaN,I=NaN)}else{const t=A.f(A.minx),e=A.f(A.maxx);v=a.getPixelForValue(A.minx),E=r.getPixelForValue(t),k=a.getPixelForValue(A.maxx),I=r.getPixelForValue(e)}let j=null;if(isFinite(v)&&isFinite(E)&&isFinite(k)&&isFinite(I)&&(j=function(t,e,i,s,n){let a=i-t,l=s-e,o=0,r=1;const h=[-a,a,-l,l],c=[t-n.left,n.right-t,e-n.top,n.bottom-e];for(let t=0;t<4;t++)if(0===h[t]){if(c[t]<0)return null}else{const e=c[t]/h[t];if(h[t]<0){if(e>r)return null;o=Math.max(o,e)}else{if(e<o)return null;r=Math.min(r,e)}}return o>r?null:{x1:t+o*a,y1:e+o*l,x2:t+r*a,y2:e+r*l}}(v,E,k,I,R)),j)if(v=j.x1,E=j.y1,k=j.x2,I=j.y2,Math.abs(v-k)<.5&&Math.abs(E-I)<.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,y),(({ctx:t,x1:e,y1:i,x2:s,y2:n,colorMin:a,colorMax:l})=>{if(isFinite(e)&&isFinite(i)&&isFinite(s)&&isFinite(n)){t.beginPath(),t.moveTo(e,i),t.lineTo(s,n);try{const o=s-e,r=n-i,h=Math.sqrt(o*o+r*r);if(h<.01)console.warn("Gradient vector too small, using solid color:",{x1:e,y1:i,x2:s,y2:n,length:h}),t.strokeStyle=a;else{let o=t.createLinearGradient(e,i,s,n);o.addColorStop(0,a),o.addColorStop(1,l),t.strokeStyle=o}}catch(e){console.warn("Gradient creation failed, using solid color:",e),t.strokeStyle=a}t.stroke(),t.closePath()}else console.warn("Cannot draw trendline: coordinates contain non-finite values",{x1:e,y1:i,x2:s,y2:n})})({ctx:s,x1:v,y1:E,x2:k,y2:I,colorMin:x,colorMax:d}),m&&((t,e,i,s,n,a,l)=>{isFinite(e)&&isFinite(i)&&isFinite(s)&&isFinite(n)&&isFinite(a)?(t.beginPath(),t.moveTo(e,i),t.lineTo(s,n),t.lineTo(s,a),t.lineTo(e,a),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:n,drawBottom:a})})(s,v,E,k,I,R.bottom,m);const t=Math.atan2(I-E,k-v);if(c.label&&!1!==F){let e=b;if(w)if(h){const t=A.coefficient(),i=A.growthRate();e=`${b} (a=${t.toFixed(2)}, b=${i.toFixed(2)})`}else{const t=A.slope();e=`${b} (Slope: ${M?(100*t).toFixed(2)+"%":t.toFixed(2)})`}((t,e,i,s,n,a,l,o,r,h,c)=>{t.font=`${h}px ${r}`,t.fillStyle=o;const u=t.measureText(e).width,x=(i+n)/2,d=(s+a)/2;t.save(),t.translate(x,d),t.rotate(l);const f=-u/2,y=c;t.fillText(e,f,y),t.restore()})(s,e,v,E,k,I,t,p,P,N,V)}}})(i.getDatasetMeta(o),s,l,n,a)}})),s.setLineDash([])},beforeInit:t=>{t.data.datasets.forEach((e=>{const i=e.trendlineLinear||e.trendlineExponential;if(i&&i.label){const s=i.label,n=t.legend.options.labels.generateLabels;t.legend.options.labels.generateLabels=function(t){const a=n(t),l=i.legend;return l&&!1!==l.display&&a.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}),a}}}))}};"undefined"!=typeof window&&window.Chart&&(window.Chart.hasOwnProperty("register")?window.Chart.register(i):window.Chart.plugins.register(i))})();
@@ -0,0 +1,245 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
7
+ <title>Exponential Trendline - Chart.js Trendline Plugin</title>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-trendline/dist/chartjs-plugin-trendline.min.js"></script>
10
+ <style>
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
13
+ margin: 0;
14
+ padding: 40px 20px;
15
+ background-color: #f8f9fa;
16
+ color: #333;
17
+ line-height: 1.6;
18
+ }
19
+ .container {
20
+ max-width: 1000px;
21
+ margin: 0 auto;
22
+ }
23
+ h1 {
24
+ text-align: center;
25
+ color: #2c3e50;
26
+ margin-bottom: 10px;
27
+ font-size: 2.5rem;
28
+ }
29
+ .subtitle {
30
+ text-align: center;
31
+ color: #7f8c8d;
32
+ margin-bottom: 40px;
33
+ font-size: 1.1rem;
34
+ }
35
+ .chart-container {
36
+ background: white;
37
+ padding: 30px;
38
+ border-radius: 8px;
39
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
40
+ margin-bottom: 30px;
41
+ }
42
+ .chart-wrapper {
43
+ position: relative;
44
+ height: 400px;
45
+ }
46
+ .info-section {
47
+ background: white;
48
+ padding: 25px;
49
+ border-radius: 8px;
50
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
51
+ margin-bottom: 20px;
52
+ }
53
+ .info-section h3 {
54
+ margin-top: 0;
55
+ color: #2c3e50;
56
+ border-bottom: 2px solid #3498db;
57
+ padding-bottom: 10px;
58
+ }
59
+ .nav-links {
60
+ text-align: center;
61
+ margin-top: 30px;
62
+ }
63
+ .nav-links a {
64
+ display: inline-block;
65
+ margin: 0 10px;
66
+ padding: 10px 20px;
67
+ background: #3498db;
68
+ color: white;
69
+ text-decoration: none;
70
+ border-radius: 4px;
71
+ font-weight: 500;
72
+ transition: background-color 0.2s;
73
+ }
74
+ .nav-links a:hover {
75
+ background: #2980b9;
76
+ }
77
+ code {
78
+ background: #f8f9fa;
79
+ padding: 2px 6px;
80
+ border-radius: 3px;
81
+ font-family: 'Monaco', 'Menlo', monospace;
82
+ }
83
+ pre {
84
+ background: #f8f9fa;
85
+ padding: 15px;
86
+ border-radius: 4px;
87
+ overflow-x: auto;
88
+ }
89
+ </style>
90
+ <script>
91
+ document.addEventListener('DOMContentLoaded', function (event) {
92
+ new Chart(document.getElementById('exponential-chart'), {
93
+ type: 'line',
94
+ data: {
95
+ labels: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
96
+ datasets: [
97
+ {
98
+ data: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024],
99
+ label: 'Exponential Growth (2^x)',
100
+ borderColor: '#3e95cd',
101
+ backgroundColor: 'rgba(62, 149, 205, 0.1)',
102
+ fill: false,
103
+ trendlineExponential: {
104
+ colorMin: '#ff6b6b',
105
+ colorMax: '#ff6b6b',
106
+ width: 2,
107
+ lineStyle: 'solid',
108
+ label: {
109
+ text: 'Exponential Trend',
110
+ display: true,
111
+ displayValue: true,
112
+ color: '#ff6b6b',
113
+ font: {
114
+ size: 12,
115
+ family: 'Arial',
116
+ },
117
+ offset: 10,
118
+ },
119
+ },
120
+ },
121
+ {
122
+ data: [100, 50, 25, 12.5, 6.25, 3.125, 1.5625, 0.78125, 0.390625, 0.1953125, 0.09765625],
123
+ label: 'Exponential Decay (100 * 0.5^x)',
124
+ borderColor: '#8e5ea2',
125
+ backgroundColor: 'rgba(142, 94, 162, 0.1)',
126
+ fill: false,
127
+ trendlineExponential: {
128
+ colorMin: '#4ecdc4',
129
+ colorMax: '#4ecdc4',
130
+ width: 2,
131
+ lineStyle: 'dashed',
132
+ label: {
133
+ text: 'Exponential Decay',
134
+ display: true,
135
+ displayValue: true,
136
+ color: '#4ecdc4',
137
+ font: {
138
+ size: 12,
139
+ family: 'Arial',
140
+ },
141
+ offset: 10,
142
+ },
143
+ },
144
+ },
145
+ ],
146
+ },
147
+ options: {
148
+ responsive: true,
149
+ maintainAspectRatio: false,
150
+ plugins: {
151
+ title: {
152
+ display: true,
153
+ text: 'Exponential Trendline Examples',
154
+ font: {
155
+ size: 16
156
+ }
157
+ },
158
+ legend: {
159
+ display: true,
160
+ },
161
+ },
162
+ scales: {
163
+ x: {
164
+ display: true,
165
+ title: {
166
+ display: true,
167
+ text: 'X Values',
168
+ },
169
+ },
170
+ y: {
171
+ display: true,
172
+ title: {
173
+ display: true,
174
+ text: 'Y Values',
175
+ },
176
+ type: 'logarithmic',
177
+ },
178
+ },
179
+ },
180
+ });
181
+ });
182
+ </script>
183
+ </head>
184
+
185
+ <body>
186
+ <div class="container">
187
+ <h1>Exponential Trendlines</h1>
188
+ <p class="subtitle">Demonstrates exponential curve fitting with growth and decay examples</p>
189
+
190
+ <div class="chart-container">
191
+ <div class="chart-wrapper">
192
+ <canvas id="exponential-chart"></canvas>
193
+ </div>
194
+ </div>
195
+
196
+ <div class="info-section">
197
+ <h3>Configuration Features</h3>
198
+ <p>This example demonstrates exponential trendline fitting using <code>trendlineExponential</code>:</p>
199
+ <ul>
200
+ <li><strong>Exponential Growth:</strong> Shows data following 2^x pattern with solid red trendline</li>
201
+ <li><strong>Exponential Decay:</strong> Shows data following 100 * 0.5^x pattern with dashed teal trendline</li>
202
+ <li><strong>Logarithmic Scale:</strong> Y-axis uses logarithmic scaling for better visualization</li>
203
+ <li><strong>Custom Labels:</strong> Both trendlines include custom labels with equation parameters</li>
204
+ </ul>
205
+ </div>
206
+
207
+ <div class="info-section">
208
+ <h3>Configuration Example</h3>
209
+ <p>To use exponential trendlines, configure your dataset with <code>trendlineExponential</code> instead of <code>trendlineLinear</code>:</p>
210
+ <pre><code>trendlineExponential: {
211
+ colorMin: '#ff6b6b',
212
+ colorMax: '#ff6b6b',
213
+ width: 2,
214
+ lineStyle: 'solid',
215
+ label: {
216
+ text: 'Exponential Trend',
217
+ display: true,
218
+ displayValue: true,
219
+ color: '#ff6b6b',
220
+ font: {
221
+ size: 12,
222
+ family: 'Arial',
223
+ },
224
+ offset: 10,
225
+ },
226
+ }</code></pre>
227
+ </div>
228
+
229
+ <div class="info-section">
230
+ <h3>Important Notes</h3>
231
+ <ul>
232
+ <li>Exponential fitting works best with positive y-values</li>
233
+ <li>The label shows the exponential parameters: a (coefficient) and b (growth rate)</li>
234
+ <li>The equation is: y = a * e^(b*x)</li>
235
+ <li>All styling options that work for linear trendlines also work for exponential</li>
236
+ </ul>
237
+ </div>
238
+
239
+ <div class="nav-links">
240
+ <a href="../index.html">← Back to Examples</a>
241
+ </div>
242
+ </div>
243
+ </body>
244
+
245
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chartjs-plugin-trendline",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "description": "Trendline for Chart.js",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1,4 +1,5 @@
1
1
  import { LineFitter } from '../utils/lineFitter';
2
+ import { ExponentialFitter } from '../utils/exponentialFitter';
2
3
  import { drawTrendline, fillBelowTrendline, setLineStyle } from '../utils/drawing';
3
4
  import { addTrendlineLabel } from './label';
4
5
 
@@ -14,6 +15,10 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
14
15
  const yAxisID = dataset.yAxisID || 'y'; // Default to 'y' if no yAxisID is specified
15
16
  const yScaleToUse = datasetMeta.controller.chart.scales[yAxisID] || yScale;
16
17
 
18
+ // Determine if we're using exponential or linear trendline
19
+ const isExponential = !!dataset.trendlineExponential;
20
+ const trendlineConfig = dataset.trendlineExponential || dataset.trendlineLinear || {};
21
+
17
22
  const defaultColor = dataset.borderColor || 'rgba(169,169,169, .6)';
18
23
  const {
19
24
  colorMin = defaultColor,
@@ -22,22 +27,22 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
22
27
  lineStyle = 'solid',
23
28
  fillColor = false,
24
29
  // trendoffset is now handled separately
25
- } = dataset.trendlineLinear || {};
26
- let trendoffset = (dataset.trendlineLinear || {}).trendoffset || 0;
30
+ } = trendlineConfig;
31
+ let trendoffset = trendlineConfig.trendoffset || 0;
27
32
 
28
33
  const {
29
34
  color = defaultColor,
30
- text = 'Trendline',
35
+ text = isExponential ? 'Exponential Trendline' : 'Trendline',
31
36
  display = true,
32
37
  displayValue = true,
33
38
  offset = 10,
34
39
  percentage = false,
35
- } = (dataset.trendlineLinear && dataset.trendlineLinear.label) || {};
40
+ } = (trendlineConfig && trendlineConfig.label) || {};
36
41
 
37
42
  const {
38
43
  family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
39
44
  size = 12,
40
- } = (dataset.trendlineLinear && dataset.trendlineLinear.label && dataset.trendlineLinear.label.font) || {};
45
+ } = (trendlineConfig && trendlineConfig.label && trendlineConfig.label.font) || {};
41
46
 
42
47
  const chartOptions = datasetMeta.controller.chart.options;
43
48
  const parsingOptions =
@@ -45,11 +50,11 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
45
50
  ? chartOptions.parsing
46
51
  : undefined;
47
52
  const xAxisKey =
48
- dataset.trendlineLinear?.xAxisKey || parsingOptions?.xAxisKey || 'x';
53
+ trendlineConfig?.xAxisKey || parsingOptions?.xAxisKey || 'x';
49
54
  const yAxisKey =
50
- dataset.trendlineLinear?.yAxisKey || parsingOptions?.yAxisKey || 'y';
55
+ trendlineConfig?.yAxisKey || parsingOptions?.yAxisKey || 'y';
51
56
 
52
- let fitter = new LineFitter();
57
+ let fitter = isExponential ? new ExponentialFitter() : new LineFitter();
53
58
 
54
59
  // --- Data Point Collection and Validation for LineFitter ---
55
60
 
@@ -156,31 +161,44 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
156
161
  const chartArea = datasetMeta.controller.chart.chartArea; // Defines the drawable area in pixels.
157
162
 
158
163
  // Determine trendline start/end points based on the 'projection' option.
159
- if (dataset.trendlineLinear.projection) {
160
- const slope = fitter.slope();
161
- const intercept = fitter.intercept();
162
- let points = [];
163
-
164
- if (Math.abs(slope) > 1e-6) {
165
- const val_y_top = yScaleToUse.getValueForPixel(chartArea.top);
166
- const x_at_top = (val_y_top - intercept) / slope;
167
- points.push({ x: x_at_top, y: val_y_top });
168
-
169
- const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom);
170
- const x_at_bottom = (val_y_bottom - intercept) / slope;
171
- points.push({ x: x_at_bottom, y: val_y_bottom });
172
- } else {
173
- points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept});
174
- points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept});
175
- }
164
+ if (trendlineConfig.projection) {
165
+ let points = [];
166
+
167
+ if (isExponential) {
168
+ // For exponential curves, we generate points across the x-axis range
169
+ const val_x_left = xScale.getValueForPixel(chartArea.left);
170
+ const y_at_left = fitter.f(val_x_left);
171
+ points.push({ x: val_x_left, y: y_at_left });
172
+
173
+ const val_x_right = xScale.getValueForPixel(chartArea.right);
174
+ const y_at_right = fitter.f(val_x_right);
175
+ points.push({ x: val_x_right, y: y_at_right });
176
+ } else {
177
+ // Linear projection logic (existing code)
178
+ const slope = fitter.slope();
179
+ const intercept = fitter.intercept();
180
+
181
+ if (Math.abs(slope) > 1e-6) {
182
+ const val_y_top = yScaleToUse.getValueForPixel(chartArea.top);
183
+ const x_at_top = (val_y_top - intercept) / slope;
184
+ points.push({ x: x_at_top, y: val_y_top });
185
+
186
+ const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom);
187
+ const x_at_bottom = (val_y_bottom - intercept) / slope;
188
+ points.push({ x: x_at_bottom, y: val_y_bottom });
189
+ } else {
190
+ points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept});
191
+ points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept});
192
+ }
176
193
 
177
- const val_x_left = xScale.getValueForPixel(chartArea.left);
178
- const y_at_left = fitter.f(val_x_left);
179
- points.push({ x: val_x_left, y: y_at_left });
194
+ const val_x_left = xScale.getValueForPixel(chartArea.left);
195
+ const y_at_left = fitter.f(val_x_left);
196
+ points.push({ x: val_x_left, y: y_at_left });
180
197
 
181
- const val_x_right = xScale.getValueForPixel(chartArea.right);
182
- const y_at_right = fitter.f(val_x_right);
183
- points.push({ x: val_x_right, y: y_at_right });
198
+ const val_x_right = xScale.getValueForPixel(chartArea.right);
199
+ const y_at_right = fitter.f(val_x_right);
200
+ points.push({ x: val_x_right, y: y_at_right });
201
+ }
184
202
 
185
203
  const chartMinX = xScale.getValueForPixel(chartArea.left);
186
204
  const chartMaxX = xScale.getValueForPixel(chartArea.right);
@@ -247,16 +265,23 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => {
247
265
  }
248
266
 
249
267
  const angle = Math.atan2(y2_px - y1_px, x2_px - x1_px);
250
- const displaySlope = fitter.slope();
251
-
252
- if (dataset.trendlineLinear.label && display !== false) {
253
- const trendText = displayValue
254
- ? `${text} (Slope: ${
255
- percentage
256
- ? (displaySlope * 100).toFixed(2) + '%'
257
- : displaySlope.toFixed(2)
258
- })`
259
- : text;
268
+
269
+ if (trendlineConfig.label && display !== false) {
270
+ let trendText = text;
271
+ if (displayValue) {
272
+ if (isExponential) {
273
+ const coefficient = fitter.coefficient();
274
+ const growthRate = fitter.growthRate();
275
+ trendText = `${text} (a=${coefficient.toFixed(2)}, b=${growthRate.toFixed(2)})`;
276
+ } else {
277
+ const displaySlope = fitter.slope();
278
+ trendText = `${text} (Slope: ${
279
+ percentage
280
+ ? (displaySlope * 100).toFixed(2) + '%'
281
+ : displaySlope.toFixed(2)
282
+ })`;
283
+ }
284
+ }
260
285
  addTrendlineLabel(
261
286
  ctx,
262
287
  trendText,
@@ -2,10 +2,12 @@ import { addFitter } from './trendline';
2
2
  import 'jest-canvas-mock';
3
3
 
4
4
  import { LineFitter } from '../utils/lineFitter';
5
+ import { ExponentialFitter } from '../utils/exponentialFitter';
5
6
  import * as drawingUtils from '../utils/drawing';
6
7
  import * as labelUtils from './label';
7
8
 
8
9
  jest.mock('../utils/lineFitter');
10
+ jest.mock('../utils/exponentialFitter');
9
11
  jest.mock('../utils/drawing', () => ({
10
12
  drawTrendline: jest.fn(),
11
13
  fillBelowTrendline: jest.fn(),
@@ -483,3 +485,305 @@ describe('addFitter', () => {
483
485
  expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
484
486
  });
485
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
+ });
@@ -10,7 +10,7 @@ export const pluginTrendlineLinear = {
10
10
 
11
11
  const sortedDatasets = chartInstance.data.datasets
12
12
  .map((dataset, index) => ({ dataset, index }))
13
- .filter((entry) => entry.dataset.trendlineLinear)
13
+ .filter((entry) => entry.dataset.trendlineLinear || entry.dataset.trendlineExponential)
14
14
  .sort((a, b) => {
15
15
  const orderA = a.dataset.order ?? 0;
16
16
  const orderB = b.dataset.order ?? 0;
@@ -42,8 +42,9 @@ export const pluginTrendlineLinear = {
42
42
  const datasets = chartInstance.data.datasets;
43
43
 
44
44
  datasets.forEach((dataset) => {
45
- if (dataset.trendlineLinear && dataset.trendlineLinear.label) {
46
- const label = dataset.trendlineLinear.label;
45
+ const trendlineConfig = dataset.trendlineLinear || dataset.trendlineExponential;
46
+ if (trendlineConfig && trendlineConfig.label) {
47
+ const label = trendlineConfig.label;
47
48
 
48
49
  // Access chartInstance to update legend labels
49
50
  const originalGenerateLabels =
@@ -54,7 +55,7 @@ export const pluginTrendlineLinear = {
54
55
  ) {
55
56
  const defaultLabels = originalGenerateLabels(chart);
56
57
 
57
- const legendConfig = dataset.trendlineLinear.legend;
58
+ const legendConfig = trendlineConfig.legend;
58
59
 
59
60
  // Display the legend is it's populated and not set to hidden
60
61
  if (legendConfig && legendConfig.display !== false) {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * A class that fits an exponential curve to a series of points using least squares.
3
+ * Fits y = a * e^(b*x) by transforming to ln(y) = ln(a) + b*x
4
+ */
5
+ export class ExponentialFitter {
6
+ constructor() {
7
+ this.count = 0;
8
+ this.sumx = 0;
9
+ this.sumlny = 0;
10
+ this.sumx2 = 0;
11
+ this.sumxlny = 0;
12
+ this.minx = Number.MAX_VALUE;
13
+ this.maxx = Number.MIN_VALUE;
14
+ this.hasValidData = true;
15
+ this.dataPoints = []; // Store data points for correlation calculation
16
+ }
17
+
18
+ /**
19
+ * Adds a point to the exponential fitter.
20
+ * @param {number} x - The x-coordinate of the point.
21
+ * @param {number} y - The y-coordinate of the point.
22
+ */
23
+ add(x, y) {
24
+ if (y <= 0) {
25
+ this.hasValidData = false;
26
+ return;
27
+ }
28
+
29
+ const lny = Math.log(y);
30
+ if (!isFinite(lny)) {
31
+ this.hasValidData = false;
32
+ return;
33
+ }
34
+
35
+ this.sumx += x;
36
+ this.sumlny += lny;
37
+ this.sumx2 += x * x;
38
+ this.sumxlny += x * lny;
39
+ if (x < this.minx) this.minx = x;
40
+ if (x > this.maxx) this.maxx = x;
41
+ this.dataPoints.push({x, y, lny}); // Store actual data points
42
+ this.count++;
43
+ }
44
+
45
+ /**
46
+ * Calculates the exponential growth rate (b in y = a * e^(b*x)).
47
+ * @returns {number} - The exponential growth rate.
48
+ */
49
+ growthRate() {
50
+ if (!this.hasValidData || this.count < 2) return 0;
51
+ const denominator = this.count * this.sumx2 - this.sumx * this.sumx;
52
+ if (Math.abs(denominator) < 1e-10) return 0;
53
+ return (this.count * this.sumxlny - this.sumx * this.sumlny) / denominator;
54
+ }
55
+
56
+ /**
57
+ * Calculates the exponential coefficient (a in y = a * e^(b*x)).
58
+ * @returns {number} - The exponential coefficient.
59
+ */
60
+ coefficient() {
61
+ if (!this.hasValidData || this.count < 2) return 1;
62
+ const lnA = (this.sumlny - this.growthRate() * this.sumx) / this.count;
63
+ return Math.exp(lnA);
64
+ }
65
+
66
+ /**
67
+ * Returns the fitted exponential value (y) for a given x.
68
+ * @param {number} x - The x-coordinate.
69
+ * @returns {number} - The corresponding y-coordinate on the fitted exponential curve.
70
+ */
71
+ f(x) {
72
+ if (!this.hasValidData || this.count < 2) return 0;
73
+ const a = this.coefficient();
74
+ const b = this.growthRate();
75
+
76
+ // Check for potential overflow before calculation
77
+ if (Math.abs(b * x) > 500) return 0; // Safer limit to prevent overflow
78
+
79
+ const result = a * Math.exp(b * x);
80
+ return isFinite(result) ? result : 0;
81
+ }
82
+
83
+ /**
84
+ * Calculates the correlation coefficient (R-squared) for the exponential fit.
85
+ * @returns {number} - The correlation coefficient (0-1).
86
+ */
87
+ correlation() {
88
+ if (!this.hasValidData || this.count < 2) return 0;
89
+
90
+ const meanLnY = this.sumlny / this.count;
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);
101
+ }
102
+
103
+ if (ssTotal === 0) return 1;
104
+ return Math.max(0, 1 - (ssRes / ssTotal));
105
+ }
106
+
107
+ /**
108
+ * Returns the scale (growth rate) of the fitted exponential curve.
109
+ * @returns {number} - The growth rate of the exponential curve.
110
+ */
111
+ scale() {
112
+ return this.growthRate();
113
+ }
114
+ }
@@ -0,0 +1,251 @@
1
+ import { ExponentialFitter } from './exponentialFitter';
2
+
3
+ describe('ExponentialFitter', () => {
4
+ describe('constructor', () => {
5
+ test('should initialize with default values', () => {
6
+ const fitter = new ExponentialFitter();
7
+ expect(fitter.count).toBe(0);
8
+ expect(fitter.sumx).toBe(0);
9
+ expect(fitter.sumlny).toBe(0);
10
+ expect(fitter.sumx2).toBe(0);
11
+ expect(fitter.sumxlny).toBe(0);
12
+ expect(fitter.minx).toBe(Number.MAX_VALUE);
13
+ expect(fitter.maxx).toBe(Number.MIN_VALUE);
14
+ expect(fitter.hasValidData).toBe(true);
15
+ });
16
+ });
17
+
18
+ describe('add', () => {
19
+ test('should add valid positive points correctly', () => {
20
+ const fitter = new ExponentialFitter();
21
+ fitter.add(0, 1);
22
+ fitter.add(1, 2);
23
+ fitter.add(2, 4);
24
+
25
+ expect(fitter.count).toBe(3);
26
+ expect(fitter.hasValidData).toBe(true);
27
+ expect(fitter.minx).toBe(0);
28
+ expect(fitter.maxx).toBe(2);
29
+ });
30
+
31
+ test('should reject negative y values', () => {
32
+ const fitter = new ExponentialFitter();
33
+ fitter.add(0, -1);
34
+ expect(fitter.hasValidData).toBe(false);
35
+ expect(fitter.count).toBe(0);
36
+ });
37
+
38
+ test('should reject zero y values', () => {
39
+ const fitter = new ExponentialFitter();
40
+ fitter.add(0, 0);
41
+ expect(fitter.hasValidData).toBe(false);
42
+ expect(fitter.count).toBe(0);
43
+ });
44
+
45
+ test('should handle infinite logarithm values', () => {
46
+ const fitter = new ExponentialFitter();
47
+ fitter.add(0, Infinity);
48
+ expect(fitter.hasValidData).toBe(false);
49
+ expect(fitter.count).toBe(0);
50
+ });
51
+ });
52
+
53
+ describe('exponential fitting', () => {
54
+ test('should fit perfect exponential growth y = 2^x', () => {
55
+ const fitter = new ExponentialFitter();
56
+ fitter.add(0, 1); // 2^0 = 1
57
+ fitter.add(1, 2); // 2^1 = 2
58
+ fitter.add(2, 4); // 2^2 = 4
59
+ fitter.add(3, 8); // 2^3 = 8
60
+
61
+ // For y = 2^x, we have y = e^(ln(2)*x)
62
+ // So coefficient should be ~1 and growth rate should be ~ln(2) ≈ 0.693
63
+ expect(fitter.coefficient()).toBeCloseTo(1, 3);
64
+ expect(fitter.growthRate()).toBeCloseTo(Math.log(2), 3);
65
+ });
66
+
67
+ test('should fit exponential decay y = e^(-x)', () => {
68
+ const fitter = new ExponentialFitter();
69
+ fitter.add(0, 1); // e^0 = 1
70
+ fitter.add(1, Math.exp(-1)); // e^(-1) ≈ 0.368
71
+ fitter.add(2, Math.exp(-2)); // e^(-2) ≈ 0.135
72
+ fitter.add(3, Math.exp(-3)); // e^(-3) ≈ 0.050
73
+
74
+ expect(fitter.coefficient()).toBeCloseTo(1, 3);
75
+ expect(fitter.growthRate()).toBeCloseTo(-1, 3);
76
+ });
77
+
78
+ test('should fit exponential with coefficient y = 3*e^(0.5*x)', () => {
79
+ const fitter = new ExponentialFitter();
80
+ const a = 3;
81
+ const b = 0.5;
82
+
83
+ for (let x = 0; x <= 4; x++) {
84
+ const y = a * Math.exp(b * x);
85
+ fitter.add(x, y);
86
+ }
87
+
88
+ expect(fitter.coefficient()).toBeCloseTo(a, 3);
89
+ expect(fitter.growthRate()).toBeCloseTo(b, 3);
90
+ });
91
+ });
92
+
93
+ describe('f', () => {
94
+ test('should return correct fitted values for exponential function', () => {
95
+ const fitter = new ExponentialFitter();
96
+ fitter.add(0, 1);
97
+ fitter.add(1, 2);
98
+ fitter.add(2, 4);
99
+ fitter.add(3, 8);
100
+
101
+ expect(fitter.f(0)).toBeCloseTo(1, 2);
102
+ expect(fitter.f(1)).toBeCloseTo(2, 2);
103
+ expect(fitter.f(2)).toBeCloseTo(4, 2);
104
+ expect(fitter.f(3)).toBeCloseTo(8, 2);
105
+ expect(fitter.f(4)).toBeCloseTo(16, 2);
106
+ });
107
+
108
+ test('should return 0 for invalid data', () => {
109
+ const fitter = new ExponentialFitter();
110
+ fitter.add(0, -1); // This makes hasValidData false
111
+
112
+ expect(fitter.f(1)).toBe(0);
113
+ });
114
+
115
+ test('should return 0 for insufficient data', () => {
116
+ const fitter = new ExponentialFitter();
117
+ fitter.add(0, 1); // Only one point
118
+
119
+ expect(fitter.f(1)).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe('growthRate and coefficient', () => {
124
+ test('should return 0 and 1 respectively for invalid data', () => {
125
+ const fitter = new ExponentialFitter();
126
+ fitter.add(0, -1);
127
+
128
+ expect(fitter.growthRate()).toBe(0);
129
+ expect(fitter.coefficient()).toBe(1);
130
+ });
131
+
132
+ test('should return 0 and 1 respectively for insufficient data', () => {
133
+ const fitter = new ExponentialFitter();
134
+ fitter.add(0, 1);
135
+
136
+ expect(fitter.growthRate()).toBe(0);
137
+ expect(fitter.coefficient()).toBe(1);
138
+ });
139
+
140
+ test('should handle degenerate case with same x values', () => {
141
+ const fitter = new ExponentialFitter();
142
+ fitter.add(1, 2);
143
+ fitter.add(1, 4);
144
+ fitter.add(1, 8);
145
+
146
+ expect(fitter.growthRate()).toBe(0);
147
+ });
148
+ });
149
+
150
+ describe('scale', () => {
151
+ test('should return the growth rate', () => {
152
+ const fitter = new ExponentialFitter();
153
+ fitter.add(0, 1);
154
+ fitter.add(1, 2);
155
+ fitter.add(2, 4);
156
+
157
+ expect(fitter.scale()).toBe(fitter.growthRate());
158
+ });
159
+ });
160
+
161
+ describe('correlation', () => {
162
+ test('should return 0 for invalid data', () => {
163
+ const fitter = new ExponentialFitter();
164
+ fitter.add(0, -1);
165
+
166
+ expect(fitter.correlation()).toBe(0);
167
+ });
168
+
169
+ test('should return 0 for insufficient data', () => {
170
+ const fitter = new ExponentialFitter();
171
+ fitter.add(0, 1);
172
+
173
+ expect(fitter.correlation()).toBe(0);
174
+ });
175
+
176
+ test('should return a value between 0 and 1', () => {
177
+ const fitter = new ExponentialFitter();
178
+ fitter.add(0, 1);
179
+ fitter.add(1, 2);
180
+ fitter.add(2, 4);
181
+ fitter.add(3, 8);
182
+
183
+ const correlation = fitter.correlation();
184
+ expect(correlation).toBeGreaterThanOrEqual(0);
185
+ expect(correlation).toBeLessThanOrEqual(1);
186
+ });
187
+
188
+ test('should return high correlation for perfect exponential data', () => {
189
+ const fitter = new ExponentialFitter();
190
+ fitter.add(0, 1); // 2^0 = 1
191
+ fitter.add(1, 2); // 2^1 = 2
192
+ fitter.add(2, 4); // 2^2 = 4
193
+ fitter.add(3, 8); // 2^3 = 8
194
+
195
+ const correlation = fitter.correlation();
196
+ expect(correlation).toBeGreaterThan(0.99); // Perfect exponential should have very high R²
197
+ });
198
+
199
+ test('should return lower correlation for noisy exponential data', () => {
200
+ const fitter = new ExponentialFitter();
201
+ fitter.add(0, 1.5); // More noisy data to ensure lower correlation
202
+ fitter.add(1, 1.8);
203
+ fitter.add(2, 5.2);
204
+ fitter.add(3, 6.1);
205
+
206
+ const correlation = fitter.correlation();
207
+ expect(correlation).toBeGreaterThan(0.5); // Still reasonable correlation
208
+ expect(correlation).toBeLessThan(0.95); // But not perfect
209
+ });
210
+ });
211
+
212
+ describe('edge cases', () => {
213
+ test('should handle very large values', () => {
214
+ const fitter = new ExponentialFitter();
215
+ fitter.add(0, 1);
216
+ fitter.add(1, 10);
217
+ fitter.add(2, 100);
218
+
219
+ expect(fitter.hasValidData).toBe(true);
220
+ expect(fitter.f(0)).toBeCloseTo(1, 1);
221
+ expect(fitter.f(1)).toBeCloseTo(10, 1);
222
+ });
223
+
224
+ test('should handle very small positive values', () => {
225
+ const fitter = new ExponentialFitter();
226
+ fitter.add(0, 0.001);
227
+ fitter.add(1, 0.01);
228
+ fitter.add(2, 0.1);
229
+
230
+ expect(fitter.hasValidData).toBe(true);
231
+ expect(fitter.f(0)).toBeCloseTo(0.001, 3);
232
+ });
233
+
234
+ test('should handle overflow gracefully', () => {
235
+ const fitter = new ExponentialFitter();
236
+ fitter.add(0, 1);
237
+ fitter.add(1, 2);
238
+ fitter.add(2, 4);
239
+
240
+ // For this data, growth rate is ln(2) ≈ 0.693
241
+ // So 1000 * 0.693 = 693, which should trigger overflow protection
242
+ const result = fitter.f(1000);
243
+ expect(result).toBe(0); // Should return 0 when overflow occurs
244
+
245
+ // Test a smaller but still large value that should work
246
+ const smallerResult = fitter.f(10);
247
+ expect(smallerResult).toBeGreaterThan(0);
248
+ expect(smallerResult).toBeLessThan(Infinity);
249
+ });
250
+ });
251
+ });