src/helpers/helpers.curve.ts
import {almostEquals, distanceBetweenPoints, sign} from './helpers.math.js';
import {_isPointInArea} from './helpers.canvas.js';
import type {ChartArea} from '../types/index.js';
import type {SplinePoint} from '../types/geometric.js';
const EPSILON = Number.EPSILON || 1e-14;
type OptionalSplinePoint = SplinePoint | false
const getPoint = (points: SplinePoint[], i: number): OptionalSplinePoint => i < points.length && !points[i].skip && points[i];
const getValueAxis = (indexAxis: 'x' | 'y') => indexAxis === 'x' ? 'y' : 'x';
export function splineCurve(
firstPoint: SplinePoint,
middlePoint: SplinePoint,
afterPoint: SplinePoint,
t: number
): {
previous: SplinePoint
next: SplinePoint
} {
// Props to Rob Spencer at scaled innovation for his post on splining between points
// http://scaledinnovation.com/analytics/splines/aboutSplines.html
// This function must also respect "skipped" points
const previous = firstPoint.skip ? middlePoint : firstPoint;
const current = middlePoint;
const next = afterPoint.skip ? middlePoint : afterPoint;
const d01 = distanceBetweenPoints(current, previous);
const d12 = distanceBetweenPoints(next, current);
let s01 = d01 / (d01 + d12);
let s12 = d12 / (d01 + d12);
// If all points are the same, s01 & s02 will be inf
s01 = isNaN(s01) ? 0 : s01;
s12 = isNaN(s12) ? 0 : s12;
const fa = t * s01; // scaling factor for triangle Ta
const fb = t * s12;
return {
previous: {
x: current.x - fa * (next.x - previous.x),
y: current.y - fa * (next.y - previous.y)
},
next: {
x: current.x + fb * (next.x - previous.x),
y: current.y + fb * (next.y - previous.y)
}
};
}
/**
* Adjust tangents to ensure monotonic properties
*/
function monotoneAdjust(points: SplinePoint[], deltaK: number[], mK: number[]) {
const pointsLen = points.length;
let alphaK: number, betaK: number, tauK: number, squaredMagnitude: number, pointCurrent: OptionalSplinePoint;
let pointAfter = getPoint(points, 0);
for (let i = 0; i < pointsLen - 1; ++i) {
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent || !pointAfter) {
continue;
}
if (almostEquals(deltaK[i], 0, EPSILON)) {
mK[i] = mK[i + 1] = 0;
continue;
}
alphaK = mK[i] / deltaK[i];
betaK = mK[i + 1] / deltaK[i];
squaredMagnitude = Math.pow(alphaK, 2) + Math.pow(betaK, 2);
if (squaredMagnitude <= 9) {
continue;
}
tauK = 3 / Math.sqrt(squaredMagnitude);
mK[i] = alphaK * tauK * deltaK[i];
mK[i + 1] = betaK * tauK * deltaK[i];
}
}
function monotoneCompute(points: SplinePoint[], mK: number[], indexAxis: 'x' | 'y' = 'x') {
const valueAxis = getValueAxis(indexAxis);
const pointsLen = points.length;
let delta: number, pointBefore: OptionalSplinePoint, pointCurrent: OptionalSplinePoint;
let pointAfter = getPoint(points, 0);
for (let i = 0; i < pointsLen; ++i) {
pointBefore = pointCurrent;
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent) {
continue;
}
const iPixel = pointCurrent[indexAxis];
const vPixel = pointCurrent[valueAxis];
if (pointBefore) {
delta = (iPixel - pointBefore[indexAxis]) / 3;
pointCurrent[`cp1${indexAxis}`] = iPixel - delta;
pointCurrent[`cp1${valueAxis}`] = vPixel - delta * mK[i];
}
if (pointAfter) {
delta = (pointAfter[indexAxis] - iPixel) / 3;
pointCurrent[`cp2${indexAxis}`] = iPixel + delta;
pointCurrent[`cp2${valueAxis}`] = vPixel + delta * mK[i];
}
}
}
/**
* This function calculates Bézier control points in a similar way than |splineCurve|,
* but preserves monotonicity of the provided data and ensures no local extremums are added
* between the dataset discrete points due to the interpolation.
* See : https://en.wikipedia.org/wiki/Monotone_cubic_interpolation
*/
export function splineCurveMonotone(points: SplinePoint[], indexAxis: 'x' | 'y' = 'x') {
const valueAxis = getValueAxis(indexAxis);
const pointsLen = points.length;
const deltaK: number[] = Array(pointsLen).fill(0);
const mK: number[] = Array(pointsLen);
// Calculate slopes (deltaK) and initialize tangents (mK)
let i, pointBefore: OptionalSplinePoint, pointCurrent: OptionalSplinePoint;
let pointAfter = getPoint(points, 0);
for (i = 0; i < pointsLen; ++i) {
pointBefore = pointCurrent;
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent) {
continue;
}
if (pointAfter) {
const slopeDelta = pointAfter[indexAxis] - pointCurrent[indexAxis];
// In the case of two points that appear at the same x pixel, slopeDeltaX is 0
deltaK[i] = slopeDelta !== 0 ? (pointAfter[valueAxis] - pointCurrent[valueAxis]) / slopeDelta : 0;
}
mK[i] = !pointBefore ? deltaK[i]
: !pointAfter ? deltaK[i - 1]
: (sign(deltaK[i - 1]) !== sign(deltaK[i])) ? 0
: (deltaK[i - 1] + deltaK[i]) / 2;
}
monotoneAdjust(points, deltaK, mK);
monotoneCompute(points, mK, indexAxis);
}
function capControlPoint(pt: number, min: number, max: number) {
return Math.max(Math.min(pt, max), min);
}
function capBezierPoints(points: SplinePoint[], area: ChartArea) {
let i, ilen, point, inArea, inAreaPrev;
let inAreaNext = _isPointInArea(points[0], area);
for (i = 0, ilen = points.length; i < ilen; ++i) {
inAreaPrev = inArea;
inArea = inAreaNext;
inAreaNext = i < ilen - 1 && _isPointInArea(points[i + 1], area);
if (!inArea) {
continue;
}
point = points[i];
if (inAreaPrev) {
point.cp1x = capControlPoint(point.cp1x, area.left, area.right);
point.cp1y = capControlPoint(point.cp1y, area.top, area.bottom);
}
if (inAreaNext) {
point.cp2x = capControlPoint(point.cp2x, area.left, area.right);
point.cp2y = capControlPoint(point.cp2y, area.top, area.bottom);
}
}
}
/**
* @private
*/
export function _updateBezierControlPoints(
points: SplinePoint[],
options,
area: ChartArea,
loop: boolean,
indexAxis: 'x' | 'y'
) {
let i: number, ilen: number, point: SplinePoint, controlPoints: ReturnType<typeof splineCurve>;
// Only consider points that are drawn in case the spanGaps option is used
if (options.spanGaps) {
points = points.filter((pt) => !pt.skip);
}
if (options.cubicInterpolationMode === 'monotone') {
splineCurveMonotone(points, indexAxis);
} else {
let prev = loop ? points[points.length - 1] : points[0];
for (i = 0, ilen = points.length; i < ilen; ++i) {
point = points[i];
controlPoints = splineCurve(
prev,
point,
points[Math.min(i + 1, ilen - (loop ? 0 : 1)) % ilen],
options.tension
);
point.cp1x = controlPoints.previous.x;
point.cp1y = controlPoints.previous.y;
point.cp2x = controlPoints.next.x;
point.cp2y = controlPoints.next.y;
prev = point;
}
}
if (options.capBezierPoints) {
capBezierPoints(points, area);
}
}