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 +59 -2
- package/changelog.md +13 -0
- package/dist/chartjs-plugin-trendline.min.js +1 -1
- package/example/exponentialChart.html +245 -0
- package/package.json +1 -1
- package/src/components/trendline.js +66 -41
- package/src/components/trendline.test.js +304 -0
- package/src/core/plugin.js +5 -4
- package/src/utils/exponentialFitter.js +114 -0
- package/src/utils/exponentialFitter.test.js +251 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# chartjs-plugin-trendline
|
|
2
2
|
|
|
3
|
-
This plugin draws
|
|
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
|
|
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,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
|
-
} =
|
|
26
|
-
let trendoffset =
|
|
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
|
-
} = (
|
|
40
|
+
} = (trendlineConfig && trendlineConfig.label) || {};
|
|
36
41
|
|
|
37
42
|
const {
|
|
38
43
|
family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
|
|
39
44
|
size = 12,
|
|
40
|
-
} = (
|
|
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
|
-
|
|
53
|
+
trendlineConfig?.xAxisKey || parsingOptions?.xAxisKey || 'x';
|
|
49
54
|
const yAxisKey =
|
|
50
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
});
|
package/src/core/plugin.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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 =
|
|
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
|
+
});
|