Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

helpers.curve cleanup #8608

Merged
merged 2 commits into from
Mar 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs/getting-started/v3-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ The following properties were renamed during v3 development:
* `helpers.drawRoundedRectangle` was renamed to `helpers.roundedRect`
* `helpers.getValueOrDefault` was renamed to `helpers.valueOrDefault`
* `LayoutItem.fullWidth` was renamed to `LayoutItem.fullSize`
* `Point.controlPointPreviousX` was renamed to `Point.cp1x`
* `Point.controlPointPreviousY` was renamed to `Point.cp1y`
* `Point.controlPointNextX` was renamed to `Point.cp2x`
* `Point.controlPointNextY` was renamed to `Point.cp2y`
* `Scale.calculateTickRotation` was renamed to `Scale.calculateLabelRotation`
* `Tooltip.options.legendColorBackgroupd` was renamed to `Tooltip.options.multiKeyBackground`

Expand Down
8 changes: 4 additions & 4 deletions src/helpers/helpers.canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,10 @@ export function _bezierCurveTo(ctx, previous, target, flip) {
return ctx.lineTo(target.x, target.y);
}
ctx.bezierCurveTo(
flip ? previous.controlPointPreviousX : previous.controlPointNextX,
flip ? previous.controlPointPreviousY : previous.controlPointNextY,
flip ? target.controlPointNextX : target.controlPointPreviousX,
flip ? target.controlPointNextY : target.controlPointPreviousY,
flip ? previous.cp1x : previous.cp2x,
flip ? previous.cp1y : previous.cp2y,
flip ? target.cp2x : target.cp1x,
flip ? target.cp2y : target.cp1y,
target.x,
target.y);
}
Expand Down
192 changes: 108 additions & 84 deletions src/helpers/helpers.curve.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {almostEquals, sign} from './helpers.math';
import {almostEquals, distanceBetweenPoints, sign} from './helpers.math';
import {_isPointInArea} from './helpers.canvas';

const EPSILON = Number.EPSILON || 1e-14;
const getPoint = (points, i) => i < points.length && !points[i].skip && points[i];

export function splineCurve(firstPoint, middlePoint, afterPoint, t) {
// Props to Rob Spencer at scaled innovation for his post on splining between points
Expand All @@ -12,9 +13,8 @@ export function splineCurve(firstPoint, middlePoint, afterPoint, t) {
const previous = firstPoint.skip ? middlePoint : firstPoint;
const current = middlePoint;
const next = afterPoint.skip ? middlePoint : afterPoint;

const d01 = Math.sqrt(Math.pow(current.x - previous.x, 2) + Math.pow(current.y - previous.y, 2));
const d12 = Math.sqrt(Math.pow(next.x - current.x, 2) + Math.pow(next.y - current.y, 2));
const d01 = distanceBetweenPoints(current, previous);
const d12 = distanceBetweenPoints(next, current);

let s01 = d01 / (d01 + d12);
let s12 = d12 / (d01 + d12);
Expand All @@ -38,114 +38,138 @@ export function splineCurve(firstPoint, middlePoint, afterPoint, t) {
};
}

export function splineCurveMonotone(points) {
// 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

const pointsWithTangents = (points || []).map((point) => ({
model: point,
deltaK: 0,
mK: 0
}));

// Calculate slopes (deltaK) and initialize tangents (mK)
const pointsLen = pointsWithTangents.length;
let i, pointBefore, pointCurrent, pointAfter;
for (i = 0; i < pointsLen; ++i) {
pointCurrent = pointsWithTangents[i];
if (pointCurrent.model.skip) {
/**
* Adjust tangents to ensure monotonic properties
*/
function monotoneAdjust(points, deltaK, mK) {
const pointsLen = points.length;

let alphaK, betaK, tauK, squaredMagnitude, pointCurrent;
let pointAfter = getPoint(points, 0);
for (let i = 0; i < pointsLen - 1; ++i) {
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent || !pointAfter) {
continue;
}

pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
if (pointAfter && !pointAfter.model.skip) {
const slopeDeltaX = (pointAfter.model.x - pointCurrent.model.x);

// In the case of two points that appear at the same x pixel, slopeDeltaX is 0
pointCurrent.deltaK = slopeDeltaX !== 0 ? (pointAfter.model.y - pointCurrent.model.y) / slopeDeltaX : 0;
if (almostEquals(deltaK[i], 0, EPSILON)) {
mK[i] = mK[i + 1] = 0;
continue;
}

if (!pointBefore || pointBefore.model.skip) {
pointCurrent.mK = pointCurrent.deltaK;
} else if (!pointAfter || pointAfter.model.skip) {
pointCurrent.mK = pointBefore.deltaK;
} else if (sign(pointBefore.deltaK) !== sign(pointCurrent.deltaK)) {
pointCurrent.mK = 0;
} else {
pointCurrent.mK = (pointBefore.deltaK + pointCurrent.deltaK) / 2;
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];
}
}

// Adjust tangents to ensure monotonic properties
let alphaK, betaK, tauK, squaredMagnitude;
for (i = 0; i < pointsLen - 1; ++i) {
pointCurrent = pointsWithTangents[i];
pointAfter = pointsWithTangents[i + 1];
if (pointCurrent.model.skip || pointAfter.model.skip) {
continue;
}
function monotoneCompute(points, mK) {
const pointsLen = points.length;
let deltaX, pointBefore, pointCurrent;
let pointAfter = getPoint(points, 0);

if (almostEquals(pointCurrent.deltaK, 0, EPSILON)) {
pointCurrent.mK = pointAfter.mK = 0;
for (let i = 0; i < pointsLen; ++i) {
pointBefore = pointCurrent;
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent) {
continue;
}

alphaK = pointCurrent.mK / pointCurrent.deltaK;
betaK = pointAfter.mK / pointCurrent.deltaK;
squaredMagnitude = Math.pow(alphaK, 2) + Math.pow(betaK, 2);
if (squaredMagnitude <= 9) {
continue;
const {x, y} = pointCurrent;
if (pointBefore) {
deltaX = (x - pointBefore.x) / 3;
pointCurrent.cp1x = x - deltaX;
pointCurrent.cp1y = y - deltaX * mK[i];
}
if (pointAfter) {
deltaX = (pointAfter.x - x) / 3;
pointCurrent.cp2x = x + deltaX;
pointCurrent.cp2y = y + deltaX * mK[i];
}

tauK = 3 / Math.sqrt(squaredMagnitude);
pointCurrent.mK = alphaK * tauK * pointCurrent.deltaK;
pointAfter.mK = betaK * tauK * pointCurrent.deltaK;
}
}

/**
* 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
*
* @param {{
* x: number,
* y: number,
* skip?: boolean,
* cp1x?: number,
* cp1y?: number,
* cp2x?: number,
* cp2y?: number,
* }[]} points
*/
export function splineCurveMonotone(points) {
const pointsLen = points.length;
const deltaK = Array(pointsLen).fill(0);
const mK = Array(pointsLen);

// Calculate slopes (deltaK) and initialize tangents (mK)
let i, pointBefore, pointCurrent;
let pointAfter = getPoint(points, 0);

// Compute control points
let deltaX;
for (i = 0; i < pointsLen; ++i) {
pointCurrent = pointsWithTangents[i];
if (pointCurrent.model.skip) {
pointBefore = pointCurrent;
pointCurrent = pointAfter;
pointAfter = getPoint(points, i + 1);
if (!pointCurrent) {
continue;
}

pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
if (pointBefore && !pointBefore.model.skip) {
deltaX = (pointCurrent.model.x - pointBefore.model.x) / 3;
pointCurrent.model.controlPointPreviousX = pointCurrent.model.x - deltaX;
pointCurrent.model.controlPointPreviousY = pointCurrent.model.y - deltaX * pointCurrent.mK;
}
if (pointAfter && !pointAfter.model.skip) {
deltaX = (pointAfter.model.x - pointCurrent.model.x) / 3;
pointCurrent.model.controlPointNextX = pointCurrent.model.x + deltaX;
pointCurrent.model.controlPointNextY = pointCurrent.model.y + deltaX * pointCurrent.mK;
if (pointAfter) {
const slopeDeltaX = (pointAfter.x - pointCurrent.x);

// In the case of two points that appear at the same x pixel, slopeDeltaX is 0
deltaK[i] = slopeDeltaX !== 0 ? (pointAfter.y - pointCurrent.y) / slopeDeltaX : 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);
}

function capControlPoint(pt, min, max) {
return Math.max(Math.min(pt, max), min);
}

function capBezierPoints(points, area) {
let i, ilen, point;
let i, ilen, point, inArea, inAreaPrev;
let inAreaNext = _isPointInArea(points[0], area);
for (i = 0, ilen = points.length; i < ilen; ++i) {
point = points[i];
if (!_isPointInArea(point, area)) {
inAreaPrev = inArea;
inArea = inAreaNext;
inAreaNext = i < ilen - 1 && _isPointInArea(points[i + 1], area);
if (!inArea) {
continue;
}
if (i > 0 && _isPointInArea(points[i - 1], area)) {
point.controlPointPreviousX = capControlPoint(point.controlPointPreviousX, area.left, area.right);
point.controlPointPreviousY = capControlPoint(point.controlPointPreviousY, area.top, area.bottom);
point = points[i];
if (inAreaPrev) {
point.cp1x = capControlPoint(point.cp1x, area.left, area.right);
point.cp1y = capControlPoint(point.cp1y, area.top, area.bottom);
}
if (i < points.length - 1 && _isPointInArea(points[i + 1], area)) {
point.controlPointNextX = capControlPoint(point.controlPointNextX, area.left, area.right);
point.controlPointNextY = capControlPoint(point.controlPointNextY, area.top, area.bottom);
if (inAreaNext) {
point.cp2x = capControlPoint(point.cp2x, area.left, area.right);
point.cp2y = capControlPoint(point.cp2y, area.top, area.bottom);
}
}
}
Expand Down Expand Up @@ -173,10 +197,10 @@ export function _updateBezierControlPoints(points, options, area, loop) {
points[Math.min(i + 1, ilen - (loop ? 0 : 1)) % ilen],
options.tension
);
point.controlPointPreviousX = controlPoints.previous.x;
point.controlPointPreviousY = controlPoints.previous.y;
point.controlPointNextX = controlPoints.next.x;
point.controlPointNextY = controlPoints.next.y;
point.cp1x = controlPoints.previous.x;
point.cp1y = controlPoints.previous.y;
point.cp2x = controlPoints.next.x;
point.cp2y = controlPoints.next.y;
prev = point;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/helpers/helpers.interpolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export function _steppedInterpolation(p1, p2, t, mode) {
* @private
*/
export function _bezierInterpolation(p1, p2, t, mode) { // eslint-disable-line no-unused-vars
const cp1 = {x: p1.controlPointNextX, y: p1.controlPointNextY};
const cp2 = {x: p2.controlPointPreviousX, y: p2.controlPointPreviousY};
const cp1 = {x: p1.cp2x, y: p1.cp2y};
const cp2 = {x: p2.cp1x, y: p2.cp1y};
const a = _pointInLine(p1, cp1, t);
const b = _pointInLine(cp1, cp2, t);
const c = _pointInLine(cp2, p2, t);
Expand Down
8 changes: 4 additions & 4 deletions test/specs/controller.radar.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ describe('Chart.controllers.radar', function() {
].forEach(function(expected, i) {
expect(meta.data[i].x).toBeCloseToPixel(expected.x);
expect(meta.data[i].y).toBeCloseToPixel(expected.y);
expect(meta.data[i].controlPointPreviousX).toBeCloseToPixel(expected.cppx);
expect(meta.data[i].controlPointPreviousY).toBeCloseToPixel(expected.cppy);
expect(meta.data[i].controlPointNextX).toBeCloseToPixel(expected.cpnx);
expect(meta.data[i].controlPointNextY).toBeCloseToPixel(expected.cpny);
expect(meta.data[i].cp1x).toBeCloseToPixel(expected.cppx);
expect(meta.data[i].cp1y).toBeCloseToPixel(expected.cppy);
expect(meta.data[i].cp2x).toBeCloseToPixel(expected.cpnx);
expect(meta.data[i].cp2y).toBeCloseToPixel(expected.cpny);
expect(meta.data[i].options).toEqual(jasmine.objectContaining({
backgroundColor: Chart.defaults.backgroundColor,
borderWidth: 1,
Expand Down