UNPKG

plotboilerplate

Version:

A simple javascript plotting boilerplate for 2d stuff.

682 lines (626 loc) 25.1 kB
/** * @author Ikaros Kappler * @date 2018-11-28 * @modified 2018-12-04 Added the toSVGString function. * @modified 2020-03-25 Ported this class from vanilla-JS to Typescript. * @modified 2021-01-20 Added UID. * @modified 2021-02-14 Added functions `radiusH` and `radiusV`. * @modified 2021-02-26 Added helper function `decribeSVGArc(...)`. * @modified 2021-03-01 Added attribute `rotation` to allow rotation of ellipses. * @modified 2021-03-03 Added the `vertAt` and `perimeter` methods. * @modified 2021-03-05 Added the `getFoci`, `normalAt` and `tangentAt` methods. * @modified 2021-03-09 Added the `clone` and `rotate` methods. * @modified 2021-03-10 Added the `toCubicBezier` method. * @modified 2021-03-15 Added `VEllipse.quarterSegmentCount` and `VEllipse.scale` functions. * @modified 2021-03-19 Added the `VEllipse.rotate` function. * @modified 2022-02-02 Added the `destroy` method. * @modified 2022-02-02 Cleared the `VEllipse.toSVGString` function (deprecated). Use `drawutilssvg` instead. * @modified 2025-03-31 ATTENTION: modified the winding direction of the `tangentAt` method to match with the Circle method. This is a breaking change! * @modified 2025-03-31 Adding the `VEllipse.move(amount: XYCoords)` method. * @modified 2025-04-19 Adding the `VEllipse.getBounds()` method. * @modified 2025-04-24 Adding the `VEllipse.getExtremePoints()` method for calculating minima and maxima. * @version 1.4.0 * * @file VEllipse * @fileoverview Ellipses with a center and an x- and a y-axis (stored as a vertex). **/ import { Line } from "./Line"; import { Vector } from "./Vector"; import { Vertex } from "./Vertex"; import { UIDGenerator } from "./UIDGenerator"; import { IBounded, Intersectable, SVGSerializable, UID, XYCoords } from "./interfaces"; import { CubicBezierCurve } from "./CubicBezierCurve"; import { VertTuple } from "./VertTuple"; import { Circle } from "./Circle"; import { Bounds } from "./Bounds"; /** * @classdesc An ellipse class based on two vertices [centerX,centerY] and [radiusX,radiusY]. * * @requires SVGSerializable * @requires UID * @requires UIDGenerator * @requires Vertex */ export class VEllipse implements IBounded, Intersectable, SVGSerializable { /** * Required to generate proper CSS classes and other class related IDs. **/ readonly className: string = "VEllipse"; /** * The UID of this drawable object. * * @member {UID} * @memberof VEllipse * @instance * @readonly */ readonly uid: UID; /** * @member {Vertex} * @memberof VEllipse * @instance */ center: Vertex; /** * @member {Vertex} * @memberof VEllipse * @instance */ axis: Vertex; /** * @member {number} * @memberof VEllipse * @instance */ rotation: number; /** * @member isDestroyed * @memberof VEllipse * @type {boolean} * @instance */ isDestroyed: boolean; /** * The constructor. * * @constructor * @param {Vertex} center - The ellipses center. * @param {Vertex} axis - The x- and y-axis (the two radii encoded in a control point). * @param {Vertex} rotation - [optional, default=0] The rotation of this ellipse. * @name VEllipse **/ constructor(center: Vertex, axis: Vertex, rotation?: number) { this.uid = UIDGenerator.next(); this.center = center; this.axis = axis; this.rotation = rotation || 0.0; } /** * Clone this ellipse (deep clone). * * @return {VEllipse} A copy of this ellipse.s */ clone(): VEllipse { return new VEllipse(this.center.clone(), this.axis.clone(), this.rotation); } /** * Get the non-negative horizonal radius of this ellipse. * * @method radiusH * @instance * @memberof VEllipse * @return {number} The unsigned horizontal radius of this ellipse. */ radiusH(): number { return Math.abs(this.signedRadiusH()); } /** * Get the signed horizonal radius of this ellipse. * * @method signedRadiusH * @instance * @memberof VEllipse * @return {number} The signed horizontal radius of this ellipse. */ signedRadiusH(): number { // return Math.abs(this.axis.x - this.center.x); // Rotate axis back to origin before calculating radius // return Math.abs(new Vertex(this.axis).rotate(-this.rotation,this.center).x - this.center.x); return new Vertex(this.axis).rotate(-this.rotation, this.center).x - this.center.x; } /** * Get the non-negative vertical radius of this ellipse. * * @method radiusV * @instance * @memberof VEllipse * @return {number} The unsigned vertical radius of this ellipse. */ radiusV(): number { return Math.abs(this.signedRadiusV()); } /** * Get the signed vertical radius of this ellipse. * * @method radiusV * @instance * @memberof VEllipse * @return {number} The signed vertical radius of this ellipse. */ signedRadiusV(): number { // Rotate axis back to origin before calculating radius return new Vertex(this.axis).rotate(-this.rotation, this.center).y - this.center.y; } /** * Get the the minima and maxima (points) of this (rotated) ellipse. * * @method getExtremePoints * @instance * @memberof VEllipse * @return {[Vertex, Vertex, Vertex, Vertex]} Get the the minima and maxima (points) of this (rotated) ellipse. */ getExtremePoints(): [Vertex, Vertex, Vertex, Vertex] { const a: number = this.radiusH(); const b: number = this.radiusV(); // Calculate t_x values const t_x1: number = Math.atan2(-b * Math.sin(this.rotation), a * Math.cos(this.rotation)); const t_x2: number = t_x1 + Math.PI; // Calculate x values at t_x const x_x1: number = this.center.x + a * Math.cos(t_x1) * Math.cos(this.rotation) - b * Math.sin(t_x1) * Math.sin(this.rotation); const y_x1: number = this.center.y + a * Math.cos(t_x1) * Math.sin(this.rotation) + b * Math.sin(t_x1) * Math.cos(this.rotation); const x_x2: number = this.center.x + a * Math.cos(t_x2) * Math.cos(this.rotation) - b * Math.sin(t_x2) * Math.sin(this.rotation); const y_x2: number = this.center.y + a * Math.cos(t_x2) * Math.sin(this.rotation) + b * Math.sin(t_x2) * Math.cos(this.rotation); let x_max: Vertex, x_min: Vertex; if (x_x1 > x_x2) { x_max = new Vertex(x_x1, y_x1); x_min = new Vertex(x_x2, y_x2); } else { x_max = new Vertex(x_x2, y_x2); x_min = new Vertex(x_x1, y_x1); } // Calculate t_y values const t_y1: number = Math.atan2(b * Math.cos(this.rotation), a * Math.sin(this.rotation)); const t_y2: number = t_y1 + Math.PI; // Calculate y values at t_y const x_y1: number = this.center.x + a * Math.cos(t_y1) * Math.cos(this.rotation) - b * Math.sin(t_y1) * Math.sin(this.rotation); const y_y1: number = this.center.y + a * Math.cos(t_y1) * Math.sin(this.rotation) + b * Math.sin(t_y1) * Math.cos(this.rotation); const x_y2: number = this.center.x + a * Math.cos(t_y2) * Math.cos(this.rotation) - b * Math.sin(t_y2) * Math.sin(this.rotation); const y_y2: number = this.center.y + a * Math.cos(t_y2) * Math.sin(this.rotation) + b * Math.sin(t_y2) * Math.cos(this.rotation); let y_max: Vertex, y_min: Vertex; if (y_y1 > y_y2) { y_max = new Vertex(x_y1, y_y1); y_min = new Vertex(x_y2, y_y2); } else { y_max = new Vertex(x_y2, y_y2); y_min = new Vertex(x_y1, y_y1); } return [x_max, x_min, y_max, y_min]; } //--- BEGIN --- Implement interface `IBounded` /** * Get the bounds of this ellipse. * * The bounds are approximated by the underlying segment buffer; the more segment there are, * the more accurate will be the returned bounds. * * @method getBounds * @instance * @memberof VEllipse * @return {Bounds} The bounds of this ellipse. **/ getBounds(): Bounds { // Thanks to Cuixiping // https://stackoverflow.com/questions/87734/how-do-you-calculate-the-axis-aligned-bounding-box-of-an-ellipse const r1: number = this.radiusH(); const r2: number = this.radiusV(); const ux: number = r1 * Math.cos(this.rotation); const uy: number = r1 * Math.sin(this.rotation); const vx: number = r2 * Math.cos(this.rotation + Math.PI / 2); const vy: number = r2 * Math.sin(this.rotation + Math.PI / 2); const bbox_halfwidth = Math.sqrt(ux * ux + vx * vx); const bbox_halfheight = Math.sqrt(uy * uy + vy * vy); return new Bounds( { x: this.center.x - bbox_halfwidth, y: this.center.y - bbox_halfheight }, { x: this.center.x + bbox_halfwidth, y: this.center.y + bbox_halfheight } ); } //--- BEGIN --- Implement interface `IBounded` /** * Move the ellipse by the given amount. This is equivalent by moving the `center` and `axis` points. * * @method move * @param {XYCoords} amount - The amount to move. * @instance * @memberof VEllipse * @return {VEllipse} this for chaining **/ move(amount: XYCoords): VEllipse { this.center.add(amount); this.axis.add(amount); return this; } /** * Scale this ellipse by the given factor from the center point. The factor will be applied to both radii. * * @method scale * @instance * @memberof VEllipse * @param {number} factor - The factor to scale by. * @return {VEllipse} this for chaining. */ scale(factor: number): VEllipse { this.axis.scale(factor, this.center); return this; } /** * Rotate this ellipse around its center. * * @method rotate * @instance * @memberof VEllipse * @param {number} angle - The angle to rotate by. * @returns {VEllipse} this for chaining. */ rotate(angle: number): VEllipse { this.axis.rotate(angle, this.center); this.rotation += angle; return this; } /** * Get the vertex on the ellipse's outline at the given angle. * * @method vertAt * @instance * @memberof VEllipse * @param {number} angle - The angle to determine the vertex at. * @return {Vertex} The vertex on the outline at the given angle. */ vertAt(angle: number): Vertex { // Tanks to Narasinham for the vertex-on-ellipse equations // https://math.stackexchange.com/questions/22064/calculating-a-point-that-lies-on-an-ellipse-given-an-angle const a: number = this.radiusH(); const b: number = this.radiusV(); return new Vertex(VEllipse.utils.polarToCartesian(this.center.x, this.center.y, a, b, angle)).rotate( this.rotation, this.center ); } /** * Get the normal vector at the given angle. * The normal vector is the vector that intersects the ellipse in a 90 degree angle * at the given point (speicified by the given angle). * * Length of desired normal vector can be specified, default is 1.0. * * @method normalAt * @instance * @memberof VEllipse * @param {number} angle - The angle to get the normal vector at. * @param {number=1.0} length - [optional, default=1] The length of the returned vector. */ normalAt(angle: number, length?: number): Vector { const point: Vertex = this.vertAt(angle - this.rotation); // HERE IS THE CORRECT BEHAVIOR! const foci: [Vertex, Vertex] = this.getFoci(); // Calculate the angle between [point,focusA] and [point,focusB] const angleA: number = new Line(point, foci[0]).angle(); const angleB: number = new Line(point, foci[1]).angle(); const centerAngle: number = angleA + (angleB - angleA) / 2.0; const endPointA: Vertex = point.clone().addX(50).clone().rotate(centerAngle, point); const endPointB: Vertex = point .clone() .addX(50) .clone() .rotate(Math.PI + centerAngle, point); const resultVector: Vector = this.center.distance(endPointA) < this.center.distance(endPointB) ? new Vector(point, endPointB) : new Vector(point, endPointA); if (typeof length === "number") { resultVector.setLength(length); } return resultVector; } /** * Get the tangent vector at the given angle. * The tangent vector is the vector that touches the ellipse exactly at the given given * point (speicified by the given angle). * * Note that the tangent is just 90 degree rotated normal vector. * * Length of desired tangent vector can be specified, default is 1.0. * * @method tangentAt * @instance * @memberof VEllipse * @param {number} angle - The angle to get the tangent vector at. * @param {number=1.0} length - [optional, default=1] The length of the returned vector. */ tangentAt(angle: number, length?: number): Vector { const normal: Vector = this.normalAt(angle, length); return normal.inv().perp(); } /** * Get the perimeter of this ellipse. * * @method perimeter * @instance * @memberof VEllipse * @return {number} */ perimeter(): number { // This method does not use an iterative approximation to determine the perimeter, but it uses // a wonderful closed approximation found by Srinivasa Ramanujan. // Matt Parker made a neat video about it: // https://www.youtube.com/watch?v=5nW3nJhBHL0 const a: number = this.radiusH(); const b: number = this.radiusV(); return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))); } /** * Get the two foci of this ellipse. * * @method getFoci * @instance * @memberof VEllipse * @return {Array<Vertex>} An array with two elements, the two focal points of the ellipse (foci). */ getFoci(): [Vertex, Vertex] { // https://www.mathopenref.com/ellipsefoci.html const rh: number = this.radiusH(); const rv: number = this.radiusV(); const sdiff: number = rh * rh - rv * rv; // f is the distance of each focs to the center. const f: number = Math.sqrt(Math.abs(sdiff)); // Foci on x- or y-axis? if (sdiff < 0) { return [ this.center.clone().addY(f).rotate(this.rotation, this.center), this.center.clone().addY(-f).rotate(this.rotation, this.center) ]; } else { return [ this.center.clone().addX(f).rotate(this.rotation, this.center), this.center.clone().addX(-f).rotate(this.rotation, this.center) ]; } } /** * Get equally distributed points on the outline of this ellipse. * * @method getEquidistantVertices * @instance * @param {number} pointCount - The number of points. * @returns {Array<Vertex>} */ getEquidistantVertices(pointCount: number): Array<Vertex> { const angles: Array<number> = VEllipse.utils.equidistantVertAngles(this.radiusH(), this.radiusV(), pointCount); const result: Array<Vertex> = []; for (var i = 0; i < angles.length; i++) { result.push(this.vertAt(angles[i])); } return result; } //--- BEGIN --- Implement interface `Intersectable` /** * Get the line intersections as vectors with this ellipse. * * @method lineIntersections * @instance * @param {VertTuple<Vector> ray - The line/ray to intersect this ellipse with. * @param {boolean} inVectorBoundsOnly - (default=false) Set to true if only intersections within the vector bounds are of interest. * @returns */ lineIntersections(ray: VertTuple<Vector>, inVectorBoundsOnly: boolean = false): Array<Vertex> { // Question: what happens to extreme versions when ellipse is a line (width or height is zero)? // This would result in a Division_by_Zero exception! // Step A: create clones for operations (keep originals unchanged) const ellipseCopy: VEllipse = this.clone(); // VEllipse const rayCopy: VertTuple<Vector> = ray.clone(); // Vector // Step B: move both so ellipse's center is located at (0,0) const moveAmount: Vertex = ellipseCopy.center.clone().inv(); ellipseCopy.move(moveAmount); rayCopy.add(moveAmount); // Step C: rotate eclipse backwards it's rotation, so that rotation is zero (0.0). // Rotate together with ray! const rotationAmount: number = -ellipseCopy.rotation; ellipseCopy.rotate(rotationAmount); // Rotation around (0,0) = center of translated ellipse rayCopy.a.rotate(rotationAmount, ellipseCopy.center); rayCopy.b.rotate(rotationAmount, ellipseCopy.center); // Step D: find x/y factors to use for scaling to transform the ellipse to a circle. // Scale together with vector ray. const radiusH: number = ellipseCopy.radiusH(); const radiusV: number = ellipseCopy.radiusV(); const scalingFactors: XYCoords = radiusH > radiusV ? { x: radiusV / radiusH, y: 1.0 } : { x: 1.0, y: radiusH / radiusV }; // Step E: scale ellipse AND ray by calculated factors. ellipseCopy.axis.scaleXY(scalingFactors); rayCopy.a.scaleXY(scalingFactors); rayCopy.b.scaleXY(scalingFactors); // Intermediate result: now the ellipse is transformed to a circle and we can calculate intersections :) // Step F: calculate circle+line intersecions const tmpCircle: Circle = new Circle(new Vertex(), ellipseCopy.radiusH()); // radiusH() === radiusV() const intersections: Vertex[] = tmpCircle.lineIntersections(rayCopy, inVectorBoundsOnly); // Step G: transform intersecions back to original configuration intersections.forEach(function (intersectionPoint) { // Reverse transformation from above. intersectionPoint.scaleXY({ x: 1 / scalingFactors.x, y: 1 / scalingFactors.y }, ellipseCopy.center); intersectionPoint.rotate(-rotationAmount, ellipseCopy.center); intersectionPoint.sub(moveAmount); }); return intersections; } /** * Get all line intersections of this polygon and their tangents along the shape. * * This method returns all intersection tangents (as vectors) with this shape. The returned array of vectors is in no specific order. * * @param line * @param lineIntersectionTangents * @returns */ lineIntersectionTangents(line: VertTuple<any>, inVectorBoundsOnly: boolean = false): Array<Vector> { // Find the intersections of all lines plus their tangents inside the circle bounds const interSectionPoints: Array<Vertex> = this.lineIntersections(line, inVectorBoundsOnly); return interSectionPoints.map((vert: Vertex) => { // Calculate angle const lineFromCenter = new Line(this.center, vert); const angle: number = lineFromCenter.angle(); // Calculate tangent at angle return this.tangentAt(angle); }); } //--- END --- Implement interface `Intersectable` /** * Convert this ellipse into cubic Bézier curves. * * @param {number=3} quarterSegmentCount - The number of segments per base elliptic quarter (default is 3, min is 1). * @param {number=0.666666} threshold - The Bézier threshold (default value 0.666666 approximates the ellipse with best results * but you might wish to use other values) * @return {Array<CubicBezierCurve>} An array of cubic Bézier curves representing this ellipse. */ toCubicBezier(quarterSegmentCount?: number, threshold?: number): Array<CubicBezierCurve> { // Math by Luc Maisonobe // http://www.spaceroots.org/documents/ellipse/node22.html // Note that ellipses with radiusH=0 or radiusV=0 cannot be represented as Bézier curves. // Return a single line here (as a Bézier curve)? // if (Math.abs(this.radiusV()) < 0.00001) { // const radiusH = this.radiusH(); // return [ // new CubicBezierCurve( // this.center.clone().addX(radiusH), // this.center.clone().addX(-radiusH), // this.center.clone(), // this.center.clone() // ) // ]; // TODO: test horizontal line ellipse // } // if (Math.abs(this.radiusH()) < 0.00001) { // const radiusV = this.radiusV(); // return [ // new CubicBezierCurve( // this.center.clone().addY(radiusV), // this.center.clone().addY(-radiusV), // this.center.clone(), // this.center.clone() // ) // ]; // TODO: test vertical line ellipse // } // At least 4, but 16 seems to be a good value. const segmentCount = Math.max(1, quarterSegmentCount || 3) * 4; threshold = typeof threshold === "undefined" ? 0.666666 : threshold; const radiusH = this.radiusH(); const radiusV = this.radiusV(); const curves: Array<CubicBezierCurve> = []; const angles: Array<number> = VEllipse.utils.equidistantVertAngles(radiusH, radiusV, segmentCount); let curAngle: number = angles[0] + this.rotation; let startPoint: Vertex = this.vertAt(curAngle); for (var i = 0; i < angles.length; i++) { let nextAngle = angles[(i + 1) % angles.length] + this.rotation; let endPoint: Vertex = this.vertAt(nextAngle); if (Math.abs(radiusV) < 0.0001 || Math.abs(radiusH) < 0.0001) { // Distorted ellipses can only be approximated by linear Bézier segments let diff: XYCoords = startPoint.difference(endPoint); let curve: CubicBezierCurve = new CubicBezierCurve( startPoint.clone(), endPoint.clone(), startPoint.clone().addXY(diff.x * 0.333, diff.y * 0.333), endPoint.clone().addXY(-diff.x * 0.333, -diff.y * 0.333) ); curves.push(curve); } else { let startTangent: Vector = this.tangentAt(curAngle + this.rotation); let endTangent: Vector = this.tangentAt(nextAngle + this.rotation); // Find intersection (ignore that the result might be null in some extreme cases) let intersection: Vertex = startTangent.intersection(endTangent) as Vertex; // What if intersection is undefined? // --> This *can* not happen if segmentCount > 2 and height and width of the ellipse are not zero. let startDiff: Vertex = startPoint.difference(intersection); let endDiff: Vertex = endPoint.difference(intersection); let curve: CubicBezierCurve = new CubicBezierCurve( startPoint.clone(), endPoint.clone(), startPoint.clone().add(startDiff.scale(threshold)), endPoint.clone().add(endDiff.scale(threshold)) ); curves.push(curve); } startPoint = endPoint; curAngle = nextAngle; } return curves; } /** * This function should invalidate any installed listeners and invalidate this object. * After calling this function the object might not hold valid data any more and * should not be used. */ destroy() { this.center.destroy(); this.axis.destroy(); this.isDestroyed = true; } /** * A static collection of ellipse-related helper functions. * @static */ static utils = { /** * Calculate a particular point on the outline of the given ellipse (center plus two radii plus angle). * * @name polarToCartesian * @param {number} centerX - The x coordinate of the elliptic center. * @param {number} centerY - The y coordinate of the elliptic center. * @param {number} radiusH - The horizontal radius of the ellipse. * @param {number} radiusV - The vertical radius of the ellipse. * @param {number} angle - The angle (in radians) to get the desired outline point for. * @reutn {XYCoords} The outlont point in absolute x-y-coordinates. */ polarToCartesian: (centerX: number, centerY: number, radiusH: number, radiusV: number, angle: number): XYCoords => { // Tanks to Narasinham for the vertex-on-ellipse equations // https://math.stackexchange.com/questions/22064/calculating-a-point-that-lies-on-an-ellipse-given-an-angle var s = Math.sin(Math.PI / 2 - angle); var c = Math.cos(Math.PI / 2 - angle); return { x: centerX + (radiusH * radiusV * s) / Math.sqrt(Math.pow(radiusH * c, 2) + Math.pow(radiusV * s, 2)), y: centerY + (radiusH * radiusV * c) / Math.sqrt(Math.pow(radiusH * c, 2) + Math.pow(radiusV * s, 2)) }; }, /** * Get the `theta` for a given `phi` (used to determine equidistant points on ellipse). * * @param radiusH * @param radiusV * @param phi * @returns {number} theta */ phiToTheta: (radiusH: number, radiusV: number, phi: number): number => { // See https://math.stackexchange.com/questions/172766/calculating-equidistant-points-around-an-ellipse-arc var tanPhi = Math.tan(phi); var tanPhi2 = tanPhi * tanPhi; var theta = -Math.PI / 2 + phi + Math.atan(((radiusH - radiusV) * tanPhi) / (radiusV + radiusH * tanPhi2)); return theta; }, /** * Get n equidistant points on the elliptic arc. * * @param pointCount * @returns */ equidistantVertAngles: (radiusH: number, radiusV: number, pointCount: number): Array<number> => { const angles: Array<number> = []; for (var i = 0; i < pointCount; i++) { var phi = Math.PI / 2.0 + ((Math.PI * 2) / pointCount) * i; let theta = VEllipse.utils.phiToTheta(radiusH, radiusV, phi); angles[i] = theta; } return angles; } }; // END utils }