@mui/x-charts
Version:
The community edition of MUI X Charts components.
276 lines (260 loc) • 8.2 kB
JavaScript
import { EPSILON } from "../../utils/epsilon.mjs";
import { getCurveFactory } from "../../internals/getCurve.mjs";
import { clampAngleRad } from "../../internals/clampAngle.mjs";
import { cubicRoots } from "../../internals/cubiqSolver.mjs";
/**
* A straight line segment.
*/
/**
* A cubic bezier segment with control points.
*/
function isBezierSegment(segment) {
return 'cpx1' in segment;
}
/**
* A minimal d3 path context that captures line/bezier segments
* instead of producing an SVG path string.
*/
class SegmentCapture {
segments = [];
cx = 0;
cy = 0;
moveTo(x, y) {
this.cx = x;
this.cy = y;
}
lineTo(x, y) {
this.segments.push({
x0: this.cx,
y0: this.cy,
x1: x,
y1: y
});
this.cx = x;
this.cy = y;
}
bezierCurveTo(cpx1, cpy1, cpx2, cpy2, x, y) {
this.segments.push({
x0: this.cx,
y0: this.cy,
cpx1,
cpy1,
cpx2,
cpy2,
x1: x,
y1: y
});
this.cx = x;
this.cy = y;
}
closePath() {}
}
/** Evaluate a cubic Bezier at parameter t. */
function cubicBezier(t, p0, p1, p2, p3) {
const mt = 1 - t;
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
}
/**
* Get polynomials coefficient of a cubic Bezier curve.
* P(t) = rep[0] * t**3 + rep[1] * t**2 + rep[2] * t + rep[3]
*/
function cubicBezierCoeffs(p0, p1, p2, p3) {
return [-p0 + 3 * p1 - 3 * p2 + p3, 3 * p0 - 6 * p1 + 3 * p2, -3 * p0 + 3 * p1, p0];
}
/**
* Find parameter t such that the segment's x(t) ≈ targetX
*/
function findTForX(segment, targetX) {
if (!isBezierSegment(segment)) {
// Linear segment.
const dx = segment.x1 - segment.x0;
return dx === 0 ? 0 : (targetX - segment.x0) / dx;
}
const xBezierCoeffs = cubicBezierCoeffs(segment.x0, segment.cpx1, segment.cpx2, segment.x1);
const polyToSolve = [...xBezierCoeffs];
polyToSolve[3] -= targetX;
const roots = cubicRoots(polyToSolve);
if (roots.length > 0) {
return roots[0];
}
return -1;
}
/**
* Find parameter t such that the segment's x(t) ≈ targetX using cubic roots.
*/
function findTForAngle(segment, targetAngle) {
if (!isBezierSegment(segment)) {
// Linear segment.
const DeltaY = segment.y1 - segment.y0;
const DeltaX = segment.x1 - segment.x0;
const dx = Math.sin(targetAngle);
const dy = -Math.cos(targetAngle);
if (Math.abs(dx) < EPSILON) {
if (Math.abs(DeltaX) < EPSILON) {
return -1;
}
return -segment.x0 / DeltaX;
}
if (Math.abs(dy) < EPSILON) {
if (Math.abs(DeltaY) < EPSILON) {
return -1;
}
return -segment.y0 / DeltaY;
}
return (segment.y0 / dy - segment.x0 / dx) / (DeltaX / dx - DeltaY / dy);
}
const xBezierCoeffs = cubicBezierCoeffs(segment.x0, segment.cpx1, segment.cpx2, segment.x1);
const yBezierCoeffs = cubicBezierCoeffs(segment.y0, segment.cpy1, segment.cpy2, segment.y1);
const targetX = Math.sin(targetAngle);
const targetY = -Math.cos(targetAngle);
const polyToSolve = [targetY * xBezierCoeffs[0] - targetX * yBezierCoeffs[0], targetY * xBezierCoeffs[1] - targetX * yBezierCoeffs[1], targetY * xBezierCoeffs[2] - targetX * yBezierCoeffs[2], targetY * xBezierCoeffs[3] - targetX * yBezierCoeffs[3]];
const roots = cubicRoots(polyToSolve);
if (roots.length > 0) {
return roots[0];
}
return -1;
}
/** Evaluate the segment's y at parameter t. */
function evaluateSegmentY(segment, t) {
if (!isBezierSegment(segment)) {
return segment.y0 + t * (segment.y1 - segment.y0);
}
return cubicBezier(t, segment.y0, segment.cpy1, segment.cpy2, segment.y1);
}
/** Evaluate the segment's x at parameter t. */
function evaluateSegmentX(segment, t) {
if (!isBezierSegment(segment)) {
return segment.x0 + t * (segment.x1 - segment.x0);
}
return cubicBezier(t, segment.x0, segment.cpx1, segment.cpx2, segment.x1);
}
/**
* Build the curve segments for a set of pixel-coordinate points
* using d3's curve factory, then evaluate y at the given pixel x.
*
* Returns null if targetX is outside the curve's x range.
*/
export function evaluateCurveY(points, targetX, curveType) {
if (points.length === 0) {
return null;
}
if (points.length === 1) {
return points[0].y;
}
const capture = new SegmentCapture();
const factory = getCurveFactory(curveType);
const curveInstance = factory(capture);
// Track which side of targetX the first point is on, so we detect the
// crossing regardless of whether x is increasing or decreasing.
const initialSide = points[0].x > targetX;
let searchStartIndex = 0;
let crossingDetected = false;
curveInstance.lineStart();
for (const p of points) {
if (!crossingDetected && p.x > targetX !== initialSide) {
searchStartIndex = Math.max(0, capture.segments.length - 1);
crossingDetected = true;
}
curveInstance.point(p.x, p.y);
}
curveInstance.lineEnd();
// Find the segment containing targetX.
for (let i = searchStartIndex; i < capture.segments.length; i += 1) {
const segment = capture.segments[i];
if (targetX < segment.x0 + 0.5 && targetX > segment.x0 - 0.5) {
return segment.y0;
}
if (targetX < segment.x1 + 0.5 && targetX > segment.x1 - 0.5) {
return segment.y1;
}
const xMin = Math.min(segment.x0, segment.x1);
const xMax = Math.max(segment.x0, segment.x1);
if (targetX >= xMin && targetX <= xMax) {
const t = findTForX(segment, targetX);
return evaluateSegmentY(segment, t);
}
}
return null;
}
const vectorProduct = (a, b) => a.x * b.y - a.y * b.x;
/**
* Build the curve segments for a set of pixel-coordinate points using d3's curve factory,
* then evaluate the point on the curve at the given angle.
*
* Returns null if no point on the curve matches the target angle.
*/
export function evaluateCurveAtAngle(
/**
* The points only uses the x/y coordinate system, because internally curve factory only works with x/y coordinates.
* So angles/radius are lost during the curve generation.
*/
points, targetAngle, curveType) {
if (points.length === 0) {
return null;
}
if (points.length === 1) {
return points[0];
}
const capture = new SegmentCapture();
const factory = getCurveFactory(curveType);
const curveInstance = factory(capture);
let pointsContainsOrigin = false;
curveInstance.lineStart();
for (const p of points) {
curveInstance.point(p.x, p.y);
if (p.x === 0 && p.y === 0) {
pointsContainsOrigin = true;
}
}
curveInstance.lineEnd();
const xTarget = Math.sin(targetAngle);
const yTarget = -Math.cos(targetAngle);
const pointTarget = {
x: xTarget,
y: yTarget
};
// Find the segment containing targetAngle.
for (const segment of capture.segments) {
const directionX0Target = vectorProduct({
x: segment.x0,
y: segment.y0
}, pointTarget);
const directionTargetX1 = vectorProduct(pointTarget, {
x: segment.x1,
y: segment.y1
});
// Test if target angle is between x0 and x1. To do so we check the sign of the vector product.
if (directionX0Target >= 0 && directionTargetX1 >= 0) {
const angle0 = Math.atan2(segment.x0, -segment.y0);
const angle1 = Math.atan2(segment.x1, -segment.y1);
const clampedAngleGap0 = clampAngleRad(targetAngle - angle0);
if (clampedAngleGap0 < EPSILON || clampedAngleGap0 > Math.PI * 2 - EPSILON) {
return {
x: segment.x0,
y: segment.y0
};
}
const clampedAngleGap1 = clampAngleRad(targetAngle - angle1);
if (clampedAngleGap1 < EPSILON || clampedAngleGap1 > Math.PI * 2 - EPSILON) {
return {
x: segment.x1,
y: segment.y1
};
}
const t = findTForAngle(segment, targetAngle);
return {
x: evaluateSegmentX(segment, t),
y: evaluateSegmentY(segment, t)
};
}
}
if (pointsContainsOrigin) {
// Frequent edge case when handling area with minRadius set to 0.
// The only point on the curve is at the origin, so we can return (0, 0) for any angle.
return {
x: 0,
y: 0
};
}
return null;
}