UNPKG

@mui/x-charts

Version:

The community edition of MUI X Charts components.

276 lines (260 loc) 8.2 kB
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; }