Skip to content

Commit

Permalink
helpers.curve cleanup (#8608)
Browse files Browse the repository at this point in the history
* helpers.curve cleanup
* Use distanceBetweenPoints
  • Loading branch information
kurkle authored Mar 10, 2021
1 parent d48a62a commit a423148
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 144 deletions.
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
Loading

0 comments on commit a423148

Please sign in to comment.