toosoon-utils
Version:
Utility functions & classes
553 lines (552 loc) • 22 kB
JavaScript
import { EPSILON } from '../../constants';
import { toDegrees } from '../../geometry';
import { LineCurve, PolylineCurve, QuadraticBezierCurve, CubicBezierCurve, CatmullRomCurve, SplineCurve, EllipseCurve, ArcCurve } from '../curves';
import { Vector2 } from '../geometry';
import Path from './Path';
/**
* Utility class for manipulating connected curves providing methods similar to the 2D Canvas API
*
* @exports
* @class PathContext
* @extends Path
* @implements CanvasRenderingContext2D
*/
export default class PathContext extends Path {
_currentPosition = new Vector2(NaN, NaN);
_currentTransform = new DOMMatrix();
_transformStack = [];
/**
* Create a path from a given list of points
*
* @param {Point2[]} points Array of points defining the path
* @return {this}
*/
setFromPoints(points) {
this.moveTo(...points[0]);
for (let i = 1, l = points.length; i < l; i++) {
this.lineTo(...points[i]);
}
return this;
}
/**
* Begin this path
*
* @return {this}
*/
beginPath() {
this._setCurrentPosition(NaN, NaN);
return this;
}
/**
* Draw a line from the ending position to the beginning position of this path
* Add an instance of {@link LineCurve} to this path
*
* @return {this}
*/
closePath() {
const startPoint = this.curves[0]?.getPoint(0);
const endPoint = this.curves[this.curves.length - 1]?.getPoint(1);
if (!startPoint.equals(endPoint)) {
const curve = new LineCurve(endPoint.x, endPoint.y, startPoint.x, startPoint.y);
this.add(curve);
}
return this;
}
/**
* Move {@link Path#currentPosition} to the coordinates specified by `x` and `y`
*
* @param {number} x X-axis coordinate of the point
* @param {number} y Y-axis coordinate of the point
* @return {this}
*/
moveTo(x, y) {
[x, y] = this._transformPoint([x, y]);
this._setCurrentPosition(x, y);
return this;
}
/**
* Draw a line from the current position to the position specified by `x` and `y`
* Add an instance of {@link LineCurve} to this path
*
* @param {number} x X-axis coordinate of the point
* @param {number} y Y-axis coordinate of the point
* @return {this}
*/
lineTo(x, y) {
[x, y] = this._transformPoint([x, y]);
if (!this._hasCurrentPosition())
return this._setCurrentPosition(x, y);
const curve = new LineCurve(this._currentPosition.x, this._currentPosition.y, x, y);
this.add(curve);
this._setCurrentPosition(x, y);
return this;
}
/**
* Draw a Polyline curve from the current position through given points
* Add an instance of {@link PolylineCurve} to this path
*
* @param {Point2[]} points Array of points defining the curve
* @returns {this}
*/
polylineTo(points) {
points = this._transformPoints(points);
if (!this._hasCurrentPosition())
this._setCurrentPosition(...points[0]);
const curve = new PolylineCurve([this._currentPosition.toArray()].concat(points));
this.add(curve);
this._setCurrentPosition(...points[points.length - 1]);
return this;
}
/**
* Draw a Quadratic Bézier curve from the current position to the end point specified by `x` and `y`, using the control point specified by `cpx` and `cpy`
* Add an instance of {@link QuadraticBezierCurve} to this path
*
* @param {number} cpx X-axis coordinate of the control point
* @param {number} cpy Y-axis coordinate of the control point
* @param {number} x2 X-axis coordinate of the end point
* @param {number} y2 Y-axis coordinate of the end point
* @return {this}
*/
quadraticCurveTo(cpx, cpy, x2, y2) {
[cpx, cpy] = this._transformPoint([cpx, cpy]);
[x2, y2] = this._transformPoint([x2, y2]);
if (!this._hasCurrentPosition())
this._setCurrentPosition(cpx, cpy);
const curve = new QuadraticBezierCurve(this._currentPosition.x, this._currentPosition.y, cpx, cpy, x2, y2);
this.add(curve);
this._setCurrentPosition(x2, y2);
return this;
}
/**
* Draw a Cubic Bézier curve from the current position to the end point specified by `x` and `y`, using the control point specified by (`cp1x`, `cp1y`) and (`cp2x`, `cp2y`)
* Add an instance of {@link CubicBezierCurve} to this path
*
* @param {number} cp1x X-axis coordinate of the first control point
* @param {number} cp1y Y-axis coordinate of the first control point
* @param {number} cp2x X-axis coordinate of the second control point
* @param {number} cp2y Y-axis coordinate of the second control point
* @param {number} x2 X-axis coordinate of the end point
* @param {number} y2 Y-axis coordinate of the end point
* @return {this}
*/
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2) {
[cp1x, cp1y] = this._transformPoint([cp1x, cp1y]);
[cp2x, cp2y] = this._transformPoint([cp2x, cp2y]);
[x2, y2] = this._transformPoint([x2, y2]);
if (!this._hasCurrentPosition())
this._setCurrentPosition(cp1x, cp1y);
const curve = new CubicBezierCurve(this._currentPosition.x, this._currentPosition.y, cp1x, cp1y, cp2x, cp2y, x2, y2);
this.add(curve);
this._setCurrentPosition(x2, y2);
return this;
}
/**
* Draw a Catmull-Rom curve from the current position to the end point specified by `x` and `y`, using the control points specified by (`cp1x`, `cp1y`) and (`cp2x`, `cp2y`)
* Add an instance of {@link CatmullRomCurve} to this path
*
* @param {number} cp1x X-axis coordinate of the first control point
* @param {number} cp1y Y-axis coordinate of the first control point
* @param {number} cp2x X-axis coordinate of the second control point
* @param {number} cp2y Y-axis coordinate of the second control point
* @param {number} x2 X-axis coordinate of the end point
* @param {number} y2 Y-axis coordinate of the end point
* @return {this}
*/
catmullRomCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2) {
[cp1x, cp1y] = this._transformPoint([cp1x, cp1y]);
[cp2x, cp2y] = this._transformPoint([cp2x, cp2y]);
[x2, y2] = this._transformPoint([x2, y2]);
if (!this._hasCurrentPosition())
this._setCurrentPosition(cp1x, cp1y);
const curve = new CatmullRomCurve(this._currentPosition.x, this._currentPosition.y, cp1x, cp1y, cp2x, cp2y, x2, y2);
this.add(curve);
this._setCurrentPosition(x2, y2);
return this;
}
/**
* Draw a Spline curve from the current position through given points
* Add an instance of {@link SplineCurve} to this path
*
* @param {Point2[]} points Array of points defining the curve
* @return {this}
*/
splineTo(points) {
points = this._transformPoints(points);
if (!this._hasCurrentPosition())
this._setCurrentPosition(...points[0]);
const curve = new SplineCurve([this._currentPosition.toArray()].concat(points));
this.add(curve);
this._setCurrentPosition(...points[points.length - 1]);
return this;
}
/**
* Draw an Ellispe curve which is centered at (`cx`, `cy`) position
* Add an instance of {@link EllipseCurve} to this path
*
* @param {number} cx X-axis coordinate of the center of the circle
* @param {number} cy Y-axis coordinate of the center of the circle
* @param {number} rx X-radius of the ellipse
* @param {number} ry Y-radius of the ellipse
* @param {number} rotation Rotation angle of the ellipse (in radians), counterclockwise from the positive X-axis
* @param {number} startAngle Start angle of the arc (in radians)
* @param {number} endAngle End angle of the arc (in radians)
* @param {boolean} [counterclockwise] Flag indicating the direction of the arc
* @return {this}
*/
ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise) {
[cx, cy, rx, ry, rotation] = this._transformEllipse(cx, cy, rx, ry, rotation);
const start = EllipseCurve.interpolate(0, cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise);
const end = EllipseCurve.interpolate(1, cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise);
if (this._hasCurrentPosition() && !this._currentPosition.equals(start)) {
const line = new LineCurve(this._currentPosition.x, this._currentPosition.y, ...start);
this.add(line);
}
if (rx <= EPSILON && ry <= EPSILON)
return this;
const curve = new EllipseCurve(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise);
this.add(curve);
this._setCurrentPosition(...end);
return this;
}
/**
* Draw an Arc curve which is centered at (`cx`, `cy`) position
* Add an instance of {@link ArcCurve} to this path
*
* @param {number} cx X-axis coordinate of the center of the circle
* @param {number} cy Y-axis coordinate of the center of the circle
* @param {number} radius Radius of the circle
* @param {number} startAngle Start angle of the arc (in radians)
* @param {number} endAngle End angle of the arc (in radians)
* @param {boolean} [counterclockwise] Flag indicating the direction of the arc
* @return {this}
*/
arc(cx, cy, radius, startAngle, endAngle, counterclockwise) {
if (!this._isUniform || this._isRotated) {
return this.ellipse(cx, cy, radius, radius, 0, startAngle, endAngle, counterclockwise);
}
[cx, cy, radius] = this._transformEllipse(cx, cy, radius, radius, 0);
const start = EllipseCurve.interpolate(0, cx, cy, radius, radius, 0, startAngle, endAngle, counterclockwise);
const end = EllipseCurve.interpolate(1, cx, cy, radius, radius, 0, startAngle, endAngle, counterclockwise);
if (this._hasCurrentPosition() && !this._currentPosition.equals(start)) {
const line = new LineCurve(this._currentPosition.x, this._currentPosition.y, ...start);
this.add(line);
}
if (radius <= EPSILON)
return this;
const curve = new ArcCurve(cx, cy, radius, startAngle, endAngle, counterclockwise);
this.add(curve);
this._setCurrentPosition(...end);
return this;
}
/**
* Draw an Arc curve from the current position, tangential to the 2 segments created by both control points
* Add an instance of {@link EllipseCurve} to this path
*
* @param {number} x1 X-axis coordinate of the first control point
* @param {number} y1 Y-axis coordinate of the first control point
* @param {number} x2 X-axis coordinate of the second control point
* @param {number} y2 Y-axis coordinate of the second control point
* @param {number} radius Arc radius (Must be non-negative)
* @returns {this}
*/
arcTo(x1, y1, x2, y2, radius) {
if (radius < 0) {
throw new Error(`IndexSizeError: Failed to execute 'arcTo' on 'PathContext': The radius provided (${radius}) is negative.`);
}
const p0 = new Vector2(...this._currentPosition).applyMatrix(this._currentTransform.inverse());
const p1 = new Vector2(x1, y1);
const p2 = new Vector2(x2, y2);
if (Vector2.equals(p0, p1)) {
return this;
}
if (Vector2.collinear(p0, p1, p2) || radius === 0) {
return this.lineTo(x1, y1);
}
const v1 = p0.clone().sub(p1).normalize();
const v2 = p2.clone().sub(p1).normalize();
const n1 = new Vector2(-v1.y, v1.x);
const n2 = new Vector2(-v2.y, v2.x);
const angle = Math.acos(v1.dot(v2));
const tangentLength = radius / Math.tan(angle / 2);
const t1 = p1.clone().add(v1.clone().multiplyScalar(tangentLength));
const t2 = p1.clone().add(v2.clone().multiplyScalar(tangentLength));
const dx = t2.x - t1.x;
const dy = t2.y - t1.y;
const determinant = n2.x * n1.y - n2.y * n1.x;
if (Math.abs(determinant) <= EPSILON) {
throw new Error(`Failed to execute 'arcTo' on 'PathContext': Failed to compute arc center.`);
}
const normalLength = (n2.x * dy - n2.y * dx) / determinant;
const c = t1.clone().add(n1.clone().multiplyScalar(normalLength));
const startAngle = Math.atan2(t1.y - c.y, t1.x - c.x);
const endAngle = Math.atan2(t2.y - c.y, t2.x - c.x);
const deltaAngle = endAngle - startAngle;
const counterclockwise = deltaAngle < 0;
t1.applyMatrix(this._currentTransform);
t2.applyMatrix(this._currentTransform);
c.applyMatrix(this._currentTransform);
const rx = this._scaleX * radius;
const ry = this._scaleY * radius;
const rotation = this._rotation;
const line = new LineCurve(this._currentPosition.x, this._currentPosition.y, t1.x, t1.y);
this.add(line);
const ellipse = new EllipseCurve(c.x, c.y, rx, ry, rotation, startAngle, endAngle, counterclockwise);
this.add(ellipse);
this._setCurrentPosition(t2.x, t2.y);
return this;
}
/**
* Draw a rectangular path from the start position specified by `x` and `y` to the end position using `width` and `height`
* Add an instance of {@link PolylineCurve} to this path
*
* @param {number} x X-axis coordinate of the rectangle starting point
* @param {number} y Y-axis coordinate of the rectangle starting point
* @param {number} width Rectangle width (Positive values are to the right and negative to the left)
* @param {number} height Rectangle height (Positive values are down, and negative are up)
* @return {this}
*/
rect(x, y, width, height) {
this.moveTo(x, y);
this.polylineTo([
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y]
]);
return this;
}
/**
* Draw a rounded rectangular path from the start position specified by `x` and `y` to the end position using `width` and `height`
* Add an instance of {@link Path} to this path
*
* @param {number} x X-axis coordinate of the rectangle starting point
* @param {number} y Y-axis coordinate of the rectangle starting point
* @param {number} width Rectangle width (Positive values are to the right and negative to the left)
* @param {number} height Rectangle height (Positive values are down, and negative are up)
* @param {number|number[]} radius Radius of the circular arc to be used for the corners of the rectangle
* @return {this}
*/
roundRect(x, y, width, height, radius) {
this.moveTo(x, y);
let topLeftRadius = typeof radius === 'number' ? radius : radius[0];
let topRightRadius = typeof radius === 'number' ? radius : radius[1] ?? radius[0];
let bottomRightRadius = typeof radius === 'number' ? radius : radius[2] ?? topLeftRadius;
let bottomLeftRadius = typeof radius === 'number' ? radius : radius[3] ?? topRightRadius;
const maxRadius = Math.min(width / 2, height / 2);
topLeftRadius = Math.min(topLeftRadius, maxRadius);
topRightRadius = Math.min(topRightRadius, maxRadius);
bottomRightRadius = Math.min(bottomRightRadius, maxRadius);
bottomLeftRadius = Math.min(bottomLeftRadius, maxRadius);
const curve = new PathContext({ autoClose: true });
curve.setTransform(this.getTransform());
// Top-Right corner
if (topRightRadius > 0) {
curve.lineTo(x + width - topRightRadius, y);
curve.arcTo(x + width, y, x + width, y + topRightRadius, topRightRadius);
}
else {
curve.lineTo(x + width, y);
}
// Bottom-Right corner
if (bottomRightRadius > 0) {
curve.lineTo(x + width, y + height - bottomRightRadius);
curve.arcTo(x + width, y + height, x + width - bottomRightRadius, y + height, bottomRightRadius);
}
else {
curve.lineTo(x + width, y + height);
}
// Bottom-Left corner
if (bottomLeftRadius > 0) {
curve.lineTo(x + bottomLeftRadius, y + height);
curve.arcTo(x, y + height, x, y + height - bottomLeftRadius, bottomLeftRadius);
}
else {
curve.lineTo(x, y + height);
}
// Top-Left corner
if (topLeftRadius > 0) {
curve.lineTo(x, y + topLeftRadius);
curve.arcTo(x, y, x + topLeftRadius, y, topLeftRadius);
}
else {
curve.lineTo(x, y);
}
this.add(curve);
return this;
}
setTransform(a, b, c, d, e, f) {
if (a instanceof DOMMatrix) {
const matrix = a;
this._currentTransform = new DOMMatrix([matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]);
}
else {
this._currentTransform = new DOMMatrix([a, b, c, d, e, f]);
}
}
getTransform() {
const { a, b, c, d, e, f } = this._currentTransform;
return new DOMMatrix([a, b, c, d, e, f]);
}
resetTransform() {
this.setTransform(1, 0, 0, 1, 0, 0);
}
transform(a, b, c, d, e, f) {
const matrix = new DOMMatrix([a, b, c, d, e, f]);
this._currentTransform = this._currentTransform.multiply(matrix);
}
translate(x, y) {
this._currentTransform = this._currentTransform.translate(x, y);
}
rotate(angle) {
this._currentTransform = this._currentTransform.rotate(toDegrees(angle));
}
scale(x, y) {
this._currentTransform = this._currentTransform.scale(x, y);
}
save() {
this._transformStack.push(this.getTransform());
}
restore() {
if (this._transformStack.length > 0) {
this._currentTransform = this._transformStack.pop();
}
}
reset() {
this.resetTransform();
this._transformStack = [];
}
_hasCurrentPosition() {
return !isNaN(this._currentPosition.x) && !isNaN(this._currentPosition.y);
}
_setCurrentPosition(x, y) {
this._currentPosition.set(x, y);
return this;
}
// ****************************
// Matrix transformations
// ****************************
_transformPoint(point) {
if (this._isIdentity)
return point;
const { x, y } = this._currentTransform.transformPoint({ x: point[0], y: point[1] });
return [x, y];
}
_transformPoints(points) {
if (this._isIdentity)
return points;
return points.map((point) => this._transformPoint(point));
}
_transformVector(vector) {
if (this._isIdentity)
return vector;
const [x0, y0] = this._transformPoint([0, 0]);
const [vx, vy] = this._transformPoint(vector);
return [vx - x0, vy - y0];
}
_transformEllipse(cx, cy, rx, ry, rotation) {
if (this._isIdentity)
return [cx, cy, rx, ry, rotation];
[cx, cy] = this._transformPoint([cx, cy]);
const [u1x, u1y] = this._transformVector([Math.cos(rotation) * rx, Math.sin(rotation) * rx]);
const [u2x, u2y] = this._transformVector([-Math.sin(rotation) * ry, Math.cos(rotation) * ry]);
rx = Math.hypot(u1x, u1y);
ry = Math.hypot(u2x, u2y);
rotation = Math.atan2(u1y, u1x);
return [cx, cy, rx, ry, rotation];
}
get _translateX() {
return this._currentTransform.e;
}
get _translateY() {
return this._currentTransform.f;
}
get _scaleX() {
const { a, b } = this._currentTransform;
return Math.hypot(a, b);
}
get _scaleY() {
const { c, d } = this._currentTransform;
return Math.hypot(c, d);
}
get _rotation() {
const { a, b } = this._currentTransform;
return Math.atan2(b, a);
}
get _isTranslated() {
return Math.abs(this._translateX) > EPSILON || Math.abs(this._translateY) > EPSILON;
}
get _isScaled() {
return Math.abs(this._scaleX - 1) > EPSILON || Math.abs(this._scaleY - 1) > EPSILON;
}
get _isRotated() {
const { b, c } = this._currentTransform;
return Math.abs(b) > EPSILON || Math.abs(c) > EPSILON;
}
get _isUniform() {
return Math.abs(this._scaleX - this._scaleY) <= EPSILON;
}
get _isIdentity() {
return this._currentTransform.isIdentity;
}
// ****************************
// Canvas 2D interface
// ****************************
canvas;
fillStyle;
strokeStyle;
lineWidth;
lineCap;
lineJoin;
lineDashOffset;
setLineDash;
getLineDash;
fillText;
strokeText;
miterLimit;
fill;
stroke;
clearRect;
fillRect;
strokeRect;
drawImage;
clip;
filter;
globalAlpha;
globalCompositeOperation;
createLinearGradient;
createRadialGradient;
createConicGradient;
createPattern;
createImageData;
getImageData;
putImageData;
imageSmoothingEnabled;
imageSmoothingQuality;
font;
fontKerning;
fontStretch;
fontVariantCaps;
letterSpacing;
textAlign;
textBaseline;
textRendering;
wordSpacing;
direction;
measureText;
shadowColor;
shadowBlur;
shadowOffsetX;
shadowOffsetY;
drawFocusIfNeeded;
isPointInPath;
isPointInStroke;
getContextAttributes() {
return {
alpha: false
};
}
isContextLost() {
return false;
}
}