UNPKG

chartjs-plugin-trendline

Version:
521 lines (460 loc) 17.9 kB
/*! * chartjs-plugin-trendline.js * Version: 2.1.7 * * Copyright 2025 Marcus Alsterfjord * Released under the MIT license * https://github.com/Makanz/chartjs-plugin-trendline/blob/master/README.md * * Modified by @vesal: accept xy-data from scatter, * Modified by @Megaemce: add label and basic legend to trendline, add JSDoc, */ /** * Chart.js plugin to draw linear trendlines on datasets. */ const pluginTrendlineLinear = { id: 'chartjs-plugin-trendline', /** * Hook that is called after datasets are drawn. * Adds trendlines to the datasets that have `trendlineLinear` configured. * @param {Chart} chartInstance - The chart instance where datasets are drawn. */ afterDatasetsDraw: (chartInstance) => { const ctx = chartInstance.ctx; const { xScale, yScale } = getScales(chartInstance); chartInstance.data.datasets.forEach((dataset, index) => { const showTrendline = dataset.alwaysShowTrendline || chartInstance.isDatasetVisible(index); if ( dataset.trendlineLinear && showTrendline && dataset.data.length > 1 ) { const datasetMeta = chartInstance.getDatasetMeta(index); addFitter(datasetMeta, ctx, dataset, xScale, yScale); } }); // Reset to solid line after drawing trendline ctx.setLineDash([]); }, beforeInit: (chartInstance) => { const datasets = chartInstance.data.datasets; datasets.forEach((dataset) => { if (dataset.trendlineLinear && dataset.trendlineLinear.label) { const label = dataset.trendlineLinear.label; // Access chartInstance to update legend labels const originalGenerateLabels = chartInstance.legend.options.labels.generateLabels; chartInstance.legend.options.labels.generateLabels = function ( chart ) { const defaultLabels = originalGenerateLabels(chart); const legendConfig = dataset.trendlineLinear.legend; // Display the legend is it's populated and not set to hidden if (legendConfig && legendConfig.display !== false) { defaultLabels.push({ text: legendConfig.text || label + ' (Trendline)', strokeStyle: legendConfig.color || dataset.borderColor || 'rgba(169,169,169, .6)', fillStyle: legendConfig.fillStyle || 'transparent', lineCap: legendConfig.lineCap || 'butt', lineDash: legendConfig.lineDash || [], lineWidth: legendConfig.width || 1, }); } return defaultLabels; }; } }); }, }; /** * Retrieves the x and y scales from the chart instance. * @param {Chart} chartInstance - The chart instance. * @returns {Object} - The xScale and yScale of the chart. */ const getScales = (chartInstance) => { let xScale, yScale; for (const scale of Object.values(chartInstance.scales)) { if (scale.isHorizontal()) xScale = scale; else yScale = scale; if (xScale && yScale) break; } return { xScale, yScale }; }; /** * Adds a trendline (fitter) to the dataset on the chart and optionally labels it with trend value. * @param {Object} datasetMeta - Metadata about the dataset. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {Object} dataset - The dataset configuration from the chart. * @param {Scale} xScale - The x-axis scale object. * @param {Scale} yScale - The y-axis scale object. */ 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; const defaultColor = dataset.borderColor || 'rgba(169,169,169, .6)'; const { colorMin = defaultColor, colorMax = defaultColor, width: lineWidth = dataset.borderWidth || 3, lineStyle = 'solid', fillColor = false, } = dataset.trendlineLinear || {}; const { color = defaultColor, text = 'Trendline', display = true, displayValue = true, offset = 10, percentage = false, } = dataset.trendlineLinear.label || {}; const { family = "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", size = 12, } = dataset.trendlineLinear.label?.font || {}; const chartOptions = datasetMeta.controller.chart.options; const parsingOptions = typeof chartOptions.parsing === 'object' ? chartOptions.parsing : undefined; const xAxisKey = dataset.trendlineLinear?.xAxisKey || parsingOptions?.xAxisKey || 'x'; const yAxisKey = dataset.trendlineLinear?.yAxisKey || parsingOptions?.yAxisKey || 'y'; let fitter = new LineFitter(); let firstIndex = dataset.data.findIndex( (d) => d !== undefined && d !== null ); let lastIndex = dataset.data.length - 1; let xy = typeof dataset.data[firstIndex] === 'object'; // Collect data points for the fitter dataset.data.forEach((data, index) => { if (data == null) return; if (['time', 'timeseries'].includes(xScale.options.type)) { let x = data[xAxisKey] != null ? data[xAxisKey] : data.t; if (x !== undefined) { fitter.add(new Date(x).getTime(), data[yAxisKey]); } else { fitter.add(index, data); } } else if (xy) { if (!isNaN(data.x) && !isNaN(data.y)) { fitter.add(data.x, data.y); } else if (!isNaN(data.x)) { fitter.add(index, data.x); } else if (!isNaN(data.y)) { fitter.add(index, data.y); } } else { fitter.add(index, data); } }); // Calculate the pixel coordinates for the trendline let x1 = xScale.getPixelForValue(fitter.minx); let y1 = yScaleToUse.getPixelForValue(fitter.f(fitter.minx)); let x2, y2; // Projection logic for trendline if (dataset.trendlineLinear.projection && fitter.scale() < 0) { let x2value = fitter.fo(); if (x2value < fitter.minx) x2value = fitter.maxx; x2 = xScale.getPixelForValue(x2value); y2 = yScaleToUse.getPixelForValue(fitter.f(x2value)); } else { x2 = xScale.getPixelForValue(fitter.maxx); y2 = yScaleToUse.getPixelForValue(fitter.f(fitter.maxx)); } // Do not use startPos and endPos directly, as they may be undefined // This was causing the vertical line issue const drawBottom = datasetMeta.controller.chart.chartArea.bottom; const chartWidth = datasetMeta.controller.chart.width; // Only adjust line for overflow if coordinates are valid if (isFinite(x1) && isFinite(y1) && isFinite(x2) && isFinite(y2)) { adjustLineForOverflow({ x1, y1, x2, y2, drawBottom, chartWidth }); // Set line width and styles ctx.lineWidth = lineWidth; setLineStyle(ctx, lineStyle); // Draw the trendline drawTrendline({ ctx, x1, y1, x2, y2, colorMin, colorMax }); // Optionally fill below the trendline if (fillColor) { fillBelowTrendline(ctx, x1, y1, x2, y2, drawBottom, fillColor); } // Calculate the angle of the trendline const angle = Math.atan2(y2 - y1, x2 - x1); // Calculate the slope of the trendline (value of trend) const slope = (y1 - y2) / (x2 - x1); // Add the label to the trendline if it's populated and not set to hidden if (dataset.trendlineLinear.label && display !== false) { const trendText = displayValue ? `${text} (Slope: ${ percentage ? (slope * 100).toFixed(2) + '%' : slope.toFixed(2) })` : text; addTrendlineLabel( ctx, trendText, x1, y1, x2, y2, angle, color, family, size, offset ); } } }; /** * Adds a label to the trendline at the calculated angle. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {string} label - The label text to add. * @param {number} x1 - The starting x-coordinate of the trendline. * @param {number} y1 - The starting y-coordinate of the trendline. * @param {number} x2 - The ending x-coordinate of the trendline. * @param {number} y2 - The ending y-coordinate of the trendline. * @param {number} angle - The angle (in radians) of the trendline. * @param {string} labelColor - The color of the label text. * @param {string} family - The font family for the label text. * @param {number} size - The font size for the label text. * @param {number} offset - The offset of the label from the trendline */ const addTrendlineLabel = ( ctx, label, x1, y1, x2, y2, angle, labelColor, family, size, offset ) => { // Set the label font and color ctx.font = `${size}px ${family}`; ctx.fillStyle = labelColor; // Label width const labelWidth = ctx.measureText(label).width; // Calculate the center of the trendline const labelX = (x1 + x2) / 2; const labelY = (y1 + y2) / 2; // Save the current state of the canvas ctx.save(); // Translate to the label position ctx.translate(labelX, labelY); // Rotate the context to align with the trendline ctx.rotate(angle); // Adjust for the length of the label and rotation const adjustedX = -labelWidth / 2; // Center the label horizontally const adjustedY = offset; // Adjust Y to compensate for the height // Draw the label ctx.fillText(label, adjustedX, adjustedY); // Restore the canvas state ctx.restore(); }; /** * Adjusts the line if it overflows below the chart bottom. * @param {Object} params - The line parameters. * @param {number} params.x1 - Starting x-coordinate of the trendline. * @param {number} params.y1 - Starting y-coordinate of the trendline. * @param {number} params.x2 - Ending x-coordinate of the trendline. * @param {number} params.y2 - Ending y-coordinate of the trendline. * @param {number} params.drawBottom - Bottom boundary of the chart. * @param {number} params.chartWidth - Width of the chart. */ const adjustLineForOverflow = ({ x1, y1, x2, y2, drawBottom, chartWidth }) => { if (y1 > drawBottom) { let diff = y1 - drawBottom; let lineHeight = y1 - y2; let overlapPercentage = diff / lineHeight; let addition = chartWidth * overlapPercentage; y1 = drawBottom; x1 = x1 + addition; } else if (y2 > drawBottom) { let diff = y2 - drawBottom; let lineHeight = y2 - y1; let overlapPercentage = diff / lineHeight; let subtraction = chartWidth - chartWidth * overlapPercentage; y2 = drawBottom; x2 = chartWidth - (x2 - subtraction); } }; /** * Sets the line style (dashed, dotted, solid) for the canvas context. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {string} lineStyle - The style of the line ('dotted', 'dashed', 'solid', etc.). */ const setLineStyle = (ctx, lineStyle) => { switch (lineStyle) { case 'dotted': ctx.setLineDash([2, 2]); break; case 'dashed': ctx.setLineDash([8, 3]); break; case 'dashdot': ctx.setLineDash([8, 3, 2, 3]); break; case 'solid': default: ctx.setLineDash([]); break; } }; /** * Draws the trendline on the canvas context. * @param {Object} params - The trendline parameters. * @param {CanvasRenderingContext2D} params.ctx - The canvas rendering context. * @param {number} params.x1 - Starting x-coordinate of the trendline. * @param {number} params.y1 - Starting y-coordinate of the trendline. * @param {number} params.x2 - Ending x-coordinate of the trendline. * @param {number} params.y2 - Ending y-coordinate of the trendline. * @param {string} params.colorMin - The starting color of the trendline gradient. * @param {string} params.colorMax - The ending color of the trendline gradient. */ const drawTrendline = ({ ctx, x1, y1, x2, y2, colorMin, colorMax }) => { // Ensure all values are finite numbers if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { console.warn( 'Cannot draw trendline: coordinates contain non-finite values', { x1, y1, x2, y2 } ); return; } ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); try { let gradient = ctx.createLinearGradient(x1, y1, x2, y2); gradient.addColorStop(0, colorMin); gradient.addColorStop(1, colorMax); ctx.strokeStyle = gradient; } catch (e) { // Fallback to solid color if gradient creation fails console.warn('Gradient creation failed, using solid color:', e); ctx.strokeStyle = colorMin; } ctx.stroke(); ctx.closePath(); }; /** * Fills the area below the trendline with the specified color. * @param {CanvasRenderingContext2D} ctx - The canvas rendering context. * @param {number} x1 - Starting x-coordinate of the trendline. * @param {number} y1 - Starting y-coordinate of the trendline. * @param {number} x2 - Ending x-coordinate of the trendline. * @param {number} y2 - Ending y-coordinate of the trendline. * @param {number} drawBottom - The bottom boundary of the chart. * @param {string} fillColor - The color to fill below the trendline. */ const fillBelowTrendline = (ctx, x1, y1, x2, y2, drawBottom, fillColor) => { // Ensure all values are finite numbers if ( !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2) || !isFinite(drawBottom) ) { console.warn( 'Cannot fill below trendline: coordinates contain non-finite values', { x1, y1, x2, y2, drawBottom } ); return; } ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x2, drawBottom); ctx.lineTo(x1, drawBottom); ctx.lineTo(x1, y1); ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill(); }; /** * A class that fits a line to a series of points using least squares. */ class LineFitter { 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; } /** * Adds a point to the line fitter. * @param {number} x - The x-coordinate of the point. * @param {number} y - The y-coordinate of the point. */ add(x, y) { this.sumx += x; this.sumy += y; this.sumx2 += x * x; this.sumxy += x * y; if (x < this.minx) this.minx = x; if (x > this.maxx) this.maxx = x; this.count++; } /** * Calculates the slope of the fitted line. * @returns {number} - The slope of the line. */ slope() { const denominator = this.count * this.sumx2 - this.sumx * this.sumx; return (this.count * this.sumxy - this.sumx * this.sumy) / denominator; } /** * Calculates the y-intercept of the fitted line. * @returns {number} - The y-intercept of the line. */ intercept() { return (this.sumy - this.slope() * this.sumx) / this.count; } /** * Returns the fitted value (y) for a given x. * @param {number} x - The x-coordinate. * @returns {number} - The corresponding y-coordinate on the fitted line. */ f(x) { return this.slope() * x + this.intercept(); } /** * Calculates the projection of the line for the future value. * @returns {number} - The future value based on the fitted line. */ fo() { return -this.intercept() / this.slope(); } /** * Returns the scale (variance) of the fitted line. * @returns {number} - The scale of the fitted line. */ scale() { return this.slope(); } } // If we're in the browser and have access to the global Chart obj, register plugin automatically if (typeof window !== 'undefined' && window.Chart) { if (window.Chart.hasOwnProperty('register')) { window.Chart.register(pluginTrendlineLinear); } else { window.Chart.plugins.register(pluginTrendlineLinear); } } // Otherwise, try to export the plugin try { module.exports = exports = pluginTrendlineLinear; } catch (e) {}