From 89583d4568fe6ab128646c0df520841f6e0c0895 Mon Sep 17 00:00:00 2001 From: Marcus Alsterfjord Date: Tue, 2 Sep 2025 22:06:53 +0200 Subject: [PATCH 1/4] Add support for exponential trendlines with corresponding tests and utility class --- src/components/trendline.js | 107 ++++++---- src/components/trendline.test.js | 304 ++++++++++++++++++++++++++++ src/core/plugin.js | 9 +- src/utils/exponentialFitter.js | 111 ++++++++++ src/utils/exponentialFitter.test.js | 228 +++++++++++++++++++++ 5 files changed, 714 insertions(+), 45 deletions(-) create mode 100644 src/utils/exponentialFitter.js create mode 100644 src/utils/exponentialFitter.test.js diff --git a/src/components/trendline.js b/src/components/trendline.js index 64497c9..4a62153 100644 --- a/src/components/trendline.js +++ b/src/components/trendline.js @@ -1,4 +1,5 @@ import { LineFitter } from '../utils/lineFitter'; +import { ExponentialFitter } from '../utils/exponentialFitter'; import { drawTrendline, fillBelowTrendline, setLineStyle } from '../utils/drawing'; import { addTrendlineLabel } from './label'; @@ -14,6 +15,10 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => { const yAxisID = dataset.yAxisID || 'y'; // Default to 'y' if no yAxisID is specified const yScaleToUse = datasetMeta.controller.chart.scales[yAxisID] || yScale; + // Determine if we're using exponential or linear trendline + const isExponential = !!dataset.trendlineExponential; + const trendlineConfig = dataset.trendlineExponential || dataset.trendlineLinear || {}; + const defaultColor = dataset.borderColor || 'rgba(169,169,169, .6)'; const { colorMin = defaultColor, @@ -22,22 +27,22 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => { lineStyle = 'solid', fillColor = false, // trendoffset is now handled separately - } = dataset.trendlineLinear || {}; - let trendoffset = (dataset.trendlineLinear || {}).trendoffset || 0; + } = trendlineConfig; + let trendoffset = trendlineConfig.trendoffset || 0; const { color = defaultColor, - text = 'Trendline', + text = isExponential ? 'Exponential Trendline' : 'Trendline', display = true, displayValue = true, offset = 10, percentage = false, - } = (dataset.trendlineLinear && dataset.trendlineLinear.label) || {}; + } = (trendlineConfig && trendlineConfig.label) || {}; const { family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", size = 12, - } = (dataset.trendlineLinear && dataset.trendlineLinear.label && dataset.trendlineLinear.label.font) || {}; + } = (trendlineConfig && trendlineConfig.label && trendlineConfig.label.font) || {}; const chartOptions = datasetMeta.controller.chart.options; const parsingOptions = @@ -45,11 +50,11 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => { ? chartOptions.parsing : undefined; const xAxisKey = - dataset.trendlineLinear?.xAxisKey || parsingOptions?.xAxisKey || 'x'; + trendlineConfig?.xAxisKey || parsingOptions?.xAxisKey || 'x'; const yAxisKey = - dataset.trendlineLinear?.yAxisKey || parsingOptions?.yAxisKey || 'y'; + trendlineConfig?.yAxisKey || parsingOptions?.yAxisKey || 'y'; - let fitter = new LineFitter(); + let fitter = isExponential ? new ExponentialFitter() : new LineFitter(); // --- Data Point Collection and Validation for LineFitter --- @@ -156,31 +161,44 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => { const chartArea = datasetMeta.controller.chart.chartArea; // Defines the drawable area in pixels. // Determine trendline start/end points based on the 'projection' option. - if (dataset.trendlineLinear.projection) { - const slope = fitter.slope(); - const intercept = fitter.intercept(); - let points = []; - - if (Math.abs(slope) > 1e-6) { - const val_y_top = yScaleToUse.getValueForPixel(chartArea.top); - const x_at_top = (val_y_top - intercept) / slope; - points.push({ x: x_at_top, y: val_y_top }); - - const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom); - const x_at_bottom = (val_y_bottom - intercept) / slope; - points.push({ x: x_at_bottom, y: val_y_bottom }); - } else { - points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept}); - points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept}); - } + if (trendlineConfig.projection) { + let points = []; + + if (isExponential) { + // For exponential curves, we generate points across the x-axis range + const val_x_left = xScale.getValueForPixel(chartArea.left); + const y_at_left = fitter.f(val_x_left); + points.push({ x: val_x_left, y: y_at_left }); + + const val_x_right = xScale.getValueForPixel(chartArea.right); + const y_at_right = fitter.f(val_x_right); + points.push({ x: val_x_right, y: y_at_right }); + } else { + // Linear projection logic (existing code) + const slope = fitter.slope(); + const intercept = fitter.intercept(); + + if (Math.abs(slope) > 1e-6) { + const val_y_top = yScaleToUse.getValueForPixel(chartArea.top); + const x_at_top = (val_y_top - intercept) / slope; + points.push({ x: x_at_top, y: val_y_top }); + + const val_y_bottom = yScaleToUse.getValueForPixel(chartArea.bottom); + const x_at_bottom = (val_y_bottom - intercept) / slope; + points.push({ x: x_at_bottom, y: val_y_bottom }); + } else { + points.push({ x: xScale.getValueForPixel(chartArea.left), y: intercept}); + points.push({ x: xScale.getValueForPixel(chartArea.right), y: intercept}); + } - const val_x_left = xScale.getValueForPixel(chartArea.left); - const y_at_left = fitter.f(val_x_left); - points.push({ x: val_x_left, y: y_at_left }); + const val_x_left = xScale.getValueForPixel(chartArea.left); + const y_at_left = fitter.f(val_x_left); + points.push({ x: val_x_left, y: y_at_left }); - const val_x_right = xScale.getValueForPixel(chartArea.right); - const y_at_right = fitter.f(val_x_right); - points.push({ x: val_x_right, y: y_at_right }); + const val_x_right = xScale.getValueForPixel(chartArea.right); + const y_at_right = fitter.f(val_x_right); + points.push({ x: val_x_right, y: y_at_right }); + } const chartMinX = xScale.getValueForPixel(chartArea.left); const chartMaxX = xScale.getValueForPixel(chartArea.right); @@ -247,16 +265,23 @@ export const addFitter = (datasetMeta, ctx, dataset, xScale, yScale) => { } const angle = Math.atan2(y2_px - y1_px, x2_px - x1_px); - const displaySlope = fitter.slope(); - - if (dataset.trendlineLinear.label && display !== false) { - const trendText = displayValue - ? `${text} (Slope: ${ - percentage - ? (displaySlope * 100).toFixed(2) + '%' - : displaySlope.toFixed(2) - })` - : text; + + if (trendlineConfig.label && display !== false) { + let trendText = text; + if (displayValue) { + if (isExponential) { + const coefficient = fitter.coefficient(); + const growthRate = fitter.growthRate(); + trendText = `${text} (a=${coefficient.toFixed(2)}, b=${growthRate.toFixed(2)})`; + } else { + const displaySlope = fitter.slope(); + trendText = `${text} (Slope: ${ + percentage + ? (displaySlope * 100).toFixed(2) + '%' + : displaySlope.toFixed(2) + })`; + } + } addTrendlineLabel( ctx, trendText, diff --git a/src/components/trendline.test.js b/src/components/trendline.test.js index 581c375..73687ce 100644 --- a/src/components/trendline.test.js +++ b/src/components/trendline.test.js @@ -2,10 +2,12 @@ import { addFitter } from './trendline'; import 'jest-canvas-mock'; import { LineFitter } from '../utils/lineFitter'; +import { ExponentialFitter } from '../utils/exponentialFitter'; import * as drawingUtils from '../utils/drawing'; import * as labelUtils from './label'; jest.mock('../utils/lineFitter'); +jest.mock('../utils/exponentialFitter'); jest.mock('../utils/drawing', () => ({ drawTrendline: jest.fn(), fillBelowTrendline: jest.fn(), @@ -483,3 +485,305 @@ describe('addFitter', () => { expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added }); }); + +describe('addFitter - Exponential Trendlines', () => { + let mockCtx; + let mockDatasetMeta; + let mockDataset; + let mockXScale; + let mockYScale; + let mockExponentialFitterInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock ExponentialFitter instance + mockExponentialFitterInstance = { + add: jest.fn(), + f: jest.fn(x => 2 * Math.exp(0.5 * x)), // Example: y = 2 * e^(0.5x) + coefficient: jest.fn(() => 2), + growthRate: jest.fn(() => 0.5), + scale: jest.fn(() => 0.5), + minx: undefined, + maxx: undefined, + count: 0, + hasValidData: true + }; + ExponentialFitter.mockImplementation(() => mockExponentialFitterInstance); + + mockCtx = { + save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(), + measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '', + strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(), + lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(), setLineDash: jest.fn(), + }; + + mockDatasetMeta = { + controller: { + chart: { + scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } }, + options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } }, + chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 }, + data: { labels: [] } + } + }, + data: [{x:0, y:0}] + }; + + mockXScale = { + getPixelForValue: jest.fn(val => val * 10), + getValueForPixel: jest.fn(pixel => pixel / 10), + options: { type: 'linear' } + }; + + mockYScale = { + getPixelForValue: jest.fn(val => val * 10), + getValueForPixel: jest.fn(pixel => pixel / 10) + }; + + mockDataset = { + data: [ { x: 0, y: 2 }, { x: 1, y: 3.3 }, { x: 2, y: 5.4 } ], // Exponential-like data + yAxisID: 'y', + borderColor: 'blue', + borderWidth: 2, + trendlineExponential: { + colorMin: 'red', + colorMax: 'red', + width: 3, + lineStyle: 'solid', + fillColor: false, + trendoffset: 0, + projection: false, + xAxisKey: 'x', + yAxisKey: 'y', + label: { + display: true, + text: 'Exponential Trend', + color: 'black', + offset: 5, + displayValue: true, + font: { family: 'Arial', size: 12 } + } + } + }; + }); + + test('Basic exponential trendline rendering', () => { + mockDatasetMeta.data = [{x:0, y:2}]; + mockExponentialFitterInstance.minx = 0; + mockExponentialFitterInstance.maxx = 2; + mockExponentialFitterInstance.count = 3; + mockExponentialFitterInstance.f = jest.fn(x => { + if (x === 0) return 2; + if (x === 2) return 5.4; + return 2 * Math.exp(0.5 * x); + }); + + mockXScale.getPixelForValue = jest.fn(val => { + if (val === 0) return 50; + if (val === 2) return 250; + return val * 100 + 50; + }); + + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => { + if (val === 2) return 200; + if (val === 5.4) return 340; + return val * 100; + }); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(ExponentialFitter).toHaveBeenCalledTimes(1); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(0, 2); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 3.3); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 5.4); + expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ + x1: 50, y1: 200, x2: 250, y2: 340 + })); + }); + + test('Exponential trendline with label showing coefficient and growth rate', () => { + mockDatasetMeta.data = [{x:0, y:2}]; + mockExponentialFitterInstance.minx = 0; + mockExponentialFitterInstance.maxx = 2; + mockExponentialFitterInstance.count = 3; + mockExponentialFitterInstance.coefficient = jest.fn(() => 2.05); + mockExponentialFitterInstance.growthRate = jest.fn(() => 0.48); + mockExponentialFitterInstance.f = jest.fn(x => { + if (x === 0) return 2; + if (x === 2) return 5.4; + return 2.05 * Math.exp(0.48 * x); + }); + + mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(labelUtils.addTrendlineLabel).toHaveBeenCalledWith( + mockCtx, + 'Exponential Trend (a=2.05, b=0.48)', + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(Number), + 'black', + 'Arial', + 12, + 5 + ); + }); + + test('Exponential trendline checks projection configuration', () => { + mockDataset.trendlineExponential.projection = true; + mockDatasetMeta.data = [{x:0, y:2}]; + mockExponentialFitterInstance.minx = 0; + mockExponentialFitterInstance.maxx = 2; + mockExponentialFitterInstance.count = 3; + mockExponentialFitterInstance.f = jest.fn(x => 2 * Math.exp(0.5 * x)); + + mockXScale.getValueForPixel = jest.fn(pixel => pixel / 100); + mockXScale.getPixelForValue = jest.fn(val => val * 100); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => pixel / 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + // Just verify that projection mode calls the boundary functions + expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(50); // left boundary + expect(mockXScale.getValueForPixel).toHaveBeenCalledWith(750); // right boundary + }); + + test('Exponential trendline with fill color', () => { + mockDataset.trendlineExponential.fillColor = 'rgba(255,0,0,0.2)'; + mockDatasetMeta.data = [{x:0, y:2}]; + mockExponentialFitterInstance.minx = 0; + mockExponentialFitterInstance.maxx = 2; + mockExponentialFitterInstance.count = 3; + mockExponentialFitterInstance.f = jest.fn(x => { + if (x === 0) return 2; + if (x === 2) return 5.4; + return 2 * Math.exp(0.5 * x); + }); + + mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith( + mockCtx, + expect.any(Number), // x1 + expect.any(Number), // y1 + expect.any(Number), // x2 + expect.any(Number), // y2 + mockDatasetMeta.controller.chart.chartArea.bottom, + 'rgba(255,0,0,0.2)' + ); + }); + + test('Exponential trendline with insufficient data points', () => { + mockDataset.data = [{ x: 0, y: 2 }]; // Only one data point + mockExponentialFitterInstance.count = 1; + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(drawingUtils.drawTrendline).not.toHaveBeenCalled(); + expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); + }); + + test('Exponential trendline with trendoffset', () => { + mockDataset.trendlineExponential.trendoffset = 1; + mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 4 }]; + mockDatasetMeta.data = [{x:0, y:1}]; + mockExponentialFitterInstance.minx = 1; + mockExponentialFitterInstance.maxx = 2; + mockExponentialFitterInstance.count = 2; + mockExponentialFitterInstance.f = jest.fn(x => { + if (x === 1) return 2; + if (x === 2) return 4; + return Math.exp(x); + }); + + mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(2); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(1, 2); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(2, 4); + expect(mockExponentialFitterInstance.add).not.toHaveBeenCalledWith(0, 1); + }); + + test('Exponential trendline with invalid data (hasValidData = false)', () => { + mockExponentialFitterInstance.hasValidData = false; + mockExponentialFitterInstance.count = 3; + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(drawingUtils.drawTrendline).not.toHaveBeenCalled(); + expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); + }); + + test('Exponential trendline with time scale', () => { + mockXScale.options.type = 'time'; + const date1 = new Date('2023-01-01T00:00:00.000Z'); + const date2 = new Date('2023-01-02T00:00:00.000Z'); + const date3 = new Date('2023-01-03T00:00:00.000Z'); + + mockDataset.data = [ + { x: date1.toISOString(), y: 2 }, + { x: date2.toISOString(), y: 4 }, + { x: date3.toISOString(), y: 8 } + ]; + mockDatasetMeta.data = [{ x: date1.toISOString(), y: 2 }]; + + const date1Ts = date1.getTime(); + const date3Ts = date3.getTime(); + + mockExponentialFitterInstance.minx = date1Ts; + mockExponentialFitterInstance.maxx = date3Ts; + mockExponentialFitterInstance.count = 3; + mockExponentialFitterInstance.f = jest.fn(x => { + if (x === date1Ts) return 2; + if (x === date3Ts) return 8; + return 2 * Math.exp(0.693 * (x - date1Ts) / (24 * 60 * 60 * 1000)); + }); + + mockXScale.getPixelForValue = jest.fn(val => val / 1000000); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(mockExponentialFitterInstance.add).toHaveBeenCalledTimes(3); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date1Ts, 2); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 4); + expect(mockExponentialFitterInstance.add).toHaveBeenCalledWith(date3Ts, 8); + }); + + test('Exponential trendline with minimal configuration (no label)', () => { + mockDataset.trendlineExponential = { + colorMin: 'blue', + width: 2, + lineStyle: 'dashed' + }; + mockDataset.data = [{ x: 0, y: 1 }, { x: 1, y: 2 }]; + mockDatasetMeta.data = [{x:0, y:1}]; + mockExponentialFitterInstance.minx = 0; + mockExponentialFitterInstance.maxx = 1; + mockExponentialFitterInstance.count = 2; + mockExponentialFitterInstance.f = jest.fn(x => Math.exp(x)); + + mockXScale.getPixelForValue = jest.fn(val => val * 100 + 50); + mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => val * 100); + + addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale); + + expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed'); + expect(drawingUtils.drawTrendline).toHaveBeenCalled(); + expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/plugin.js b/src/core/plugin.js index 878e641..216678a 100644 --- a/src/core/plugin.js +++ b/src/core/plugin.js @@ -10,7 +10,7 @@ export const pluginTrendlineLinear = { const sortedDatasets = chartInstance.data.datasets .map((dataset, index) => ({ dataset, index })) - .filter((entry) => entry.dataset.trendlineLinear) + .filter((entry) => entry.dataset.trendlineLinear || entry.dataset.trendlineExponential) .sort((a, b) => { const orderA = a.dataset.order ?? 0; const orderB = b.dataset.order ?? 0; @@ -42,8 +42,9 @@ export const pluginTrendlineLinear = { const datasets = chartInstance.data.datasets; datasets.forEach((dataset) => { - if (dataset.trendlineLinear && dataset.trendlineLinear.label) { - const label = dataset.trendlineLinear.label; + const trendlineConfig = dataset.trendlineLinear || dataset.trendlineExponential; + if (trendlineConfig && trendlineConfig.label) { + const label = trendlineConfig.label; // Access chartInstance to update legend labels const originalGenerateLabels = @@ -54,7 +55,7 @@ export const pluginTrendlineLinear = { ) { const defaultLabels = originalGenerateLabels(chart); - const legendConfig = dataset.trendlineLinear.legend; + const legendConfig = trendlineConfig.legend; // Display the legend is it's populated and not set to hidden if (legendConfig && legendConfig.display !== false) { diff --git a/src/utils/exponentialFitter.js b/src/utils/exponentialFitter.js new file mode 100644 index 0000000..a79bda2 --- /dev/null +++ b/src/utils/exponentialFitter.js @@ -0,0 +1,111 @@ +/** + * A class that fits an exponential curve to a series of points using least squares. + * Fits y = a * e^(b*x) by transforming to ln(y) = ln(a) + b*x + */ +export class ExponentialFitter { + 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 = true; + } + + /** + * Adds a point to the exponential fitter. + * @param {number} x - The x-coordinate of the point. + * @param {number} y - The y-coordinate of the point. + */ + add(x, y) { + if (y <= 0) { + this.hasValidData = false; + return; + } + + const lny = Math.log(y); + if (!isFinite(lny)) { + this.hasValidData = false; + return; + } + + this.sumx += x; + this.sumlny += lny; + this.sumx2 += x * x; + this.sumxlny += x * lny; + if (x < this.minx) this.minx = x; + if (x > this.maxx) this.maxx = x; + this.count++; + } + + /** + * Calculates the exponential growth rate (b in y = a * e^(b*x)). + * @returns {number} - The exponential growth rate. + */ + growthRate() { + if (!this.hasValidData || this.count < 2) return 0; + const denominator = this.count * this.sumx2 - this.sumx * this.sumx; + if (Math.abs(denominator) < 1e-10) return 0; + return (this.count * this.sumxlny - this.sumx * this.sumlny) / denominator; + } + + /** + * Calculates the exponential coefficient (a in y = a * e^(b*x)). + * @returns {number} - The exponential coefficient. + */ + coefficient() { + if (!this.hasValidData || this.count < 2) return 1; + const lnA = (this.sumlny - this.growthRate() * this.sumx) / this.count; + return Math.exp(lnA); + } + + /** + * Returns the fitted exponential value (y) for a given x. + * @param {number} x - The x-coordinate. + * @returns {number} - The corresponding y-coordinate on the fitted exponential curve. + */ + f(x) { + if (!this.hasValidData || this.count < 2) return 0; + const a = this.coefficient(); + const b = this.growthRate(); + + // Check for potential overflow before calculation + if (Math.abs(b * x) > 500) return 0; // Safer limit to prevent overflow + + const result = a * Math.exp(b * x); + return isFinite(result) ? result : 0; + } + + /** + * Calculates the correlation coefficient (R-squared) for the exponential fit. + * @returns {number} - The correlation coefficient (0-1). + */ + correlation() { + if (!this.hasValidData || this.count < 2) return 0; + + const meanLnY = this.sumlny / this.count; + const meanX = this.sumx / this.count; + + let ssTotal = 0; + let ssRes = 0; + + for (let i = 0; i < this.count; i++) { + const predictedLnY = Math.log(this.coefficient()) + this.growthRate() * meanX; + ssTotal += Math.pow(meanLnY - meanLnY, 2); + ssRes += Math.pow(meanLnY - predictedLnY, 2); + } + + if (ssTotal === 0) return 1; + return Math.max(0, 1 - (ssRes / ssTotal)); + } + + /** + * Returns the scale (growth rate) of the fitted exponential curve. + * @returns {number} - The growth rate of the exponential curve. + */ + scale() { + return this.growthRate(); + } +} \ No newline at end of file diff --git a/src/utils/exponentialFitter.test.js b/src/utils/exponentialFitter.test.js new file mode 100644 index 0000000..7fcee36 --- /dev/null +++ b/src/utils/exponentialFitter.test.js @@ -0,0 +1,228 @@ +import { ExponentialFitter } from './exponentialFitter'; + +describe('ExponentialFitter', () => { + describe('constructor', () => { + test('should initialize with default values', () => { + const fitter = new ExponentialFitter(); + expect(fitter.count).toBe(0); + expect(fitter.sumx).toBe(0); + expect(fitter.sumlny).toBe(0); + expect(fitter.sumx2).toBe(0); + expect(fitter.sumxlny).toBe(0); + expect(fitter.minx).toBe(Number.MAX_VALUE); + expect(fitter.maxx).toBe(Number.MIN_VALUE); + expect(fitter.hasValidData).toBe(true); + }); + }); + + describe('add', () => { + test('should add valid positive points correctly', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 2); + fitter.add(2, 4); + + expect(fitter.count).toBe(3); + expect(fitter.hasValidData).toBe(true); + expect(fitter.minx).toBe(0); + expect(fitter.maxx).toBe(2); + }); + + test('should reject negative y values', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, -1); + expect(fitter.hasValidData).toBe(false); + expect(fitter.count).toBe(0); + }); + + test('should reject zero y values', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 0); + expect(fitter.hasValidData).toBe(false); + expect(fitter.count).toBe(0); + }); + + test('should handle infinite logarithm values', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, Infinity); + expect(fitter.hasValidData).toBe(false); + expect(fitter.count).toBe(0); + }); + }); + + describe('exponential fitting', () => { + test('should fit perfect exponential growth y = 2^x', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); // 2^0 = 1 + fitter.add(1, 2); // 2^1 = 2 + fitter.add(2, 4); // 2^2 = 4 + fitter.add(3, 8); // 2^3 = 8 + + // For y = 2^x, we have y = e^(ln(2)*x) + // So coefficient should be ~1 and growth rate should be ~ln(2) ≈ 0.693 + expect(fitter.coefficient()).toBeCloseTo(1, 3); + expect(fitter.growthRate()).toBeCloseTo(Math.log(2), 3); + }); + + test('should fit exponential decay y = e^(-x)', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); // e^0 = 1 + fitter.add(1, Math.exp(-1)); // e^(-1) ≈ 0.368 + fitter.add(2, Math.exp(-2)); // e^(-2) ≈ 0.135 + fitter.add(3, Math.exp(-3)); // e^(-3) ≈ 0.050 + + expect(fitter.coefficient()).toBeCloseTo(1, 3); + expect(fitter.growthRate()).toBeCloseTo(-1, 3); + }); + + test('should fit exponential with coefficient y = 3*e^(0.5*x)', () => { + const fitter = new ExponentialFitter(); + const a = 3; + const b = 0.5; + + for (let x = 0; x <= 4; x++) { + const y = a * Math.exp(b * x); + fitter.add(x, y); + } + + expect(fitter.coefficient()).toBeCloseTo(a, 3); + expect(fitter.growthRate()).toBeCloseTo(b, 3); + }); + }); + + describe('f', () => { + test('should return correct fitted values for exponential function', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 2); + fitter.add(2, 4); + fitter.add(3, 8); + + expect(fitter.f(0)).toBeCloseTo(1, 2); + expect(fitter.f(1)).toBeCloseTo(2, 2); + expect(fitter.f(2)).toBeCloseTo(4, 2); + expect(fitter.f(3)).toBeCloseTo(8, 2); + expect(fitter.f(4)).toBeCloseTo(16, 2); + }); + + test('should return 0 for invalid data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, -1); // This makes hasValidData false + + expect(fitter.f(1)).toBe(0); + }); + + test('should return 0 for insufficient data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); // Only one point + + expect(fitter.f(1)).toBe(0); + }); + }); + + describe('growthRate and coefficient', () => { + test('should return 0 and 1 respectively for invalid data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, -1); + + expect(fitter.growthRate()).toBe(0); + expect(fitter.coefficient()).toBe(1); + }); + + test('should return 0 and 1 respectively for insufficient data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + + expect(fitter.growthRate()).toBe(0); + expect(fitter.coefficient()).toBe(1); + }); + + test('should handle degenerate case with same x values', () => { + const fitter = new ExponentialFitter(); + fitter.add(1, 2); + fitter.add(1, 4); + fitter.add(1, 8); + + expect(fitter.growthRate()).toBe(0); + }); + }); + + describe('scale', () => { + test('should return the growth rate', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 2); + fitter.add(2, 4); + + expect(fitter.scale()).toBe(fitter.growthRate()); + }); + }); + + describe('correlation', () => { + test('should return 0 for invalid data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, -1); + + expect(fitter.correlation()).toBe(0); + }); + + test('should return 0 for insufficient data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + + expect(fitter.correlation()).toBe(0); + }); + + test('should return a value between 0 and 1', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 2); + fitter.add(2, 4); + fitter.add(3, 8); + + const correlation = fitter.correlation(); + expect(correlation).toBeGreaterThanOrEqual(0); + expect(correlation).toBeLessThanOrEqual(1); + }); + }); + + describe('edge cases', () => { + test('should handle very large values', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 10); + fitter.add(2, 100); + + expect(fitter.hasValidData).toBe(true); + expect(fitter.f(0)).toBeCloseTo(1, 1); + expect(fitter.f(1)).toBeCloseTo(10, 1); + }); + + test('should handle very small positive values', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 0.001); + fitter.add(1, 0.01); + fitter.add(2, 0.1); + + expect(fitter.hasValidData).toBe(true); + expect(fitter.f(0)).toBeCloseTo(0.001, 3); + }); + + test('should handle overflow gracefully', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); + fitter.add(1, 2); + fitter.add(2, 4); + + // For this data, growth rate is ln(2) ≈ 0.693 + // So 1000 * 0.693 = 693, which should trigger overflow protection + const result = fitter.f(1000); + expect(result).toBe(0); // Should return 0 when overflow occurs + + // Test a smaller but still large value that should work + const smallerResult = fitter.f(10); + expect(smallerResult).toBeGreaterThan(0); + expect(smallerResult).toBeLessThan(Infinity); + }); + }); +}); \ No newline at end of file From e77a14d0b7fcd38744556d293d0afd570071d873 Mon Sep 17 00:00:00 2001 From: Marcus Alsterfjord Date: Tue, 2 Sep 2025 22:07:03 +0200 Subject: [PATCH 2/4] Update changelog for version 3.1.0: Add exponential trendline support and documentation --- changelog.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/changelog.md b/changelog.md index f17581b..ca2be65 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,16 @@ +# Changelog 3.1.0 + +### New Features +- Added exponential trendline support with `trendlineExponential` configuration +- Exponential trendlines fit curves of the form y = a × e^(b×x) +- All existing styling options (colors, width, lineStyle, projection, etc.) work with exponential trendlines +- Added comprehensive test coverage for exponential functionality +- Added exponential trendline example (exponentialChart.html) + +### Improvements +- Updated README with exponential trendline documentation +- Enhanced package description to mention exponential support + # Changelog 3.0.0 ### Breaking Changes From 76741dd7c964bbe13fd75d11b0cc841773bcbf92 Mon Sep 17 00:00:00 2001 From: Marcus Alsterfjord Date: Tue, 2 Sep 2025 22:07:12 +0200 Subject: [PATCH 3/4] Update README and add example for exponential trendlines support --- README.md | 61 ++++++++- example/exponentialChart.html | 245 ++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 example/exponentialChart.html diff --git a/README.md b/README.md index d3a280c..f3bed66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # chartjs-plugin-trendline -This plugin draws an linear trendline in your Chart. +This plugin draws linear and exponential trendlines in your Chart. It has been tested with Chart.js version 4.4.9. ## 📊 [View Live Examples](https://makanz.github.io/chartjs-plugin-trendline/) @@ -35,6 +35,10 @@ ChartJS.plugins.register(chartTrendline); To configure the trendline plugin you simply add a new config options to your dataset in your chart config. +### Linear Trendlines + +For linear trendlines (straight lines), use the `trendlineLinear` configuration: + ```javascript { trendlineLinear: { @@ -51,7 +55,7 @@ To configure the trendline plugin you simply add a new config options to your da color: Color, text: string, display: boolean, - displayValue: boolean, + displayValue: boolean, // shows slope value offset: number, percentage: boolean, font: { @@ -72,12 +76,65 @@ To configure the trendline plugin you simply add a new config options to your da } ``` +### Exponential Trendlines + +For exponential trendlines (curves of the form y = a × e^(b×x)), use the `trendlineExponential` configuration: + +```javascript +{ + trendlineExponential: { + colorMin: Color, + colorMax: Color, + lineStyle: string, // "dotted" | "solid" | "dashed" | "dashdot" + width: number, + xAxisKey: string, // optional + yAxisKey: string, // optional + projection: boolean, // optional + trendoffset: number, // optional, if > 0 skips first n elements, if < 0 uses last n elements + // optional + label: { + color: Color, + text: string, + display: boolean, + displayValue: boolean, // shows exponential parameters (a, b) + offset: number, + font: { + family: string, + size: number, + } + }, + // optional + legend: { + text: string, + strokeStyle: Color, + fillStyle: Color, + lineCap: string, + lineDash: number[], + lineWidth: number, + } + } +} +``` + +**Note:** Exponential trendlines work best with positive y-values. The equation fitted is y = a × e^(b×x), where: +- `a` is the coefficient (scaling factor) +- `b` is the growth rate (positive for growth, negative for decay) + +## Examples + +- [Linear Trendline Example](./example/lineChart.html) +- [Exponential Trendline Example](./example/exponentialChart.html) +- [Bar Chart with Trendline](./example/barChart.html) +- [Scatter Chart with Trendline](./example/scatterChart.html) + ## Supported chart types - bar - line - scatter +Both linear and exponential trendlines are supported for all chart types. + ## Contributing Pull requests and issues are always welcome. diff --git a/example/exponentialChart.html b/example/exponentialChart.html new file mode 100644 index 0000000..d497635 --- /dev/null +++ b/example/exponentialChart.html @@ -0,0 +1,245 @@ + + + + + + + Exponential Trendline - Chart.js Trendline Plugin + + + + + + + +
+

Exponential Trendlines

+

Demonstrates exponential curve fitting with growth and decay examples

+ +
+
+ +
+
+ +
+

Configuration Features

+

This example demonstrates exponential trendline fitting using trendlineExponential:

+
    +
  • Exponential Growth: Shows data following 2^x pattern with solid red trendline
  • +
  • Exponential Decay: Shows data following 100 * 0.5^x pattern with dashed teal trendline
  • +
  • Logarithmic Scale: Y-axis uses logarithmic scaling for better visualization
  • +
  • Custom Labels: Both trendlines include custom labels with equation parameters
  • +
+
+ +
+

Configuration Example

+

To use exponential trendlines, configure your dataset with trendlineExponential instead of trendlineLinear:

+
trendlineExponential: {
+    colorMin: '#ff6b6b',
+    colorMax: '#ff6b6b',
+    width: 2,
+    lineStyle: 'solid',
+    label: {
+        text: 'Exponential Trend',
+        display: true,
+        displayValue: true,
+        color: '#ff6b6b',
+        font: {
+            size: 12,
+            family: 'Arial',
+        },
+        offset: 10,
+    },
+}
+
+ +
+

Important Notes

+
    +
  • Exponential fitting works best with positive y-values
  • +
  • The label shows the exponential parameters: a (coefficient) and b (growth rate)
  • +
  • The equation is: y = a * e^(b*x)
  • +
  • All styling options that work for linear trendlines also work for exponential
  • +
+
+ + +
+ + + \ No newline at end of file From 8c608c45795e6e730b5f71e0f9b2648ad5b4cb98 Mon Sep 17 00:00:00 2001 From: Marcus Alsterfjord Date: Tue, 2 Sep 2025 22:50:34 +0200 Subject: [PATCH 4/4] Enhance ExponentialFitter to calculate correlation and add tests for perfect and noisy exponential data --- dist/chartjs-plugin-trendline.min.js | 2 +- src/utils/exponentialFitter.js | 13 ++++++++----- src/utils/exponentialFitter.test.js | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/dist/chartjs-plugin-trendline.min.js b/dist/chartjs-plugin-trendline.min.js index 8f65fcc..d313653 100644 --- a/dist/chartjs-plugin-trendline.min.js +++ b/dist/chartjs-plugin-trendline.min.js @@ -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,ethis.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{if(null!=e&&!(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(tr?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))})(); \ No newline at end of file +(()=>{"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,tthis.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,tthis.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{if(null!=t&&!(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(er?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))})(); \ No newline at end of file diff --git a/src/utils/exponentialFitter.js b/src/utils/exponentialFitter.js index a79bda2..ea53805 100644 --- a/src/utils/exponentialFitter.js +++ b/src/utils/exponentialFitter.js @@ -12,6 +12,7 @@ export class ExponentialFitter { this.minx = Number.MAX_VALUE; this.maxx = Number.MIN_VALUE; this.hasValidData = true; + this.dataPoints = []; // Store data points for correlation calculation } /** @@ -37,6 +38,7 @@ export class ExponentialFitter { this.sumxlny += x * lny; if (x < this.minx) this.minx = x; if (x > this.maxx) this.maxx = x; + this.dataPoints.push({x, y, lny}); // Store actual data points this.count++; } @@ -86,15 +88,16 @@ export class ExponentialFitter { if (!this.hasValidData || this.count < 2) return 0; const meanLnY = this.sumlny / this.count; - const meanX = this.sumx / this.count; + const lnA = Math.log(this.coefficient()); + const b = this.growthRate(); let ssTotal = 0; let ssRes = 0; - for (let i = 0; i < this.count; i++) { - const predictedLnY = Math.log(this.coefficient()) + this.growthRate() * meanX; - ssTotal += Math.pow(meanLnY - meanLnY, 2); - ssRes += Math.pow(meanLnY - predictedLnY, 2); + for (const point of this.dataPoints) { + const predictedLnY = lnA + b * point.x; + ssTotal += Math.pow(point.lny - meanLnY, 2); + ssRes += Math.pow(point.lny - predictedLnY, 2); } if (ssTotal === 0) return 1; diff --git a/src/utils/exponentialFitter.test.js b/src/utils/exponentialFitter.test.js index 7fcee36..3d4b25e 100644 --- a/src/utils/exponentialFitter.test.js +++ b/src/utils/exponentialFitter.test.js @@ -184,6 +184,29 @@ describe('ExponentialFitter', () => { expect(correlation).toBeGreaterThanOrEqual(0); expect(correlation).toBeLessThanOrEqual(1); }); + + test('should return high correlation for perfect exponential data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1); // 2^0 = 1 + fitter.add(1, 2); // 2^1 = 2 + fitter.add(2, 4); // 2^2 = 4 + fitter.add(3, 8); // 2^3 = 8 + + const correlation = fitter.correlation(); + expect(correlation).toBeGreaterThan(0.99); // Perfect exponential should have very high R² + }); + + test('should return lower correlation for noisy exponential data', () => { + const fitter = new ExponentialFitter(); + fitter.add(0, 1.5); // More noisy data to ensure lower correlation + fitter.add(1, 1.8); + fitter.add(2, 5.2); + fitter.add(3, 6.1); + + const correlation = fitter.correlation(); + expect(correlation).toBeGreaterThan(0.5); // Still reasonable correlation + expect(correlation).toBeLessThan(0.95); // But not perfect + }); }); describe('edge cases', () => {