chartjs-plugin-trendline 3.0.3 → 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.
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,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))})();
@@ -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.1",
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,