@js-draw/math 1.0.0 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/Color4.test.d.ts +1 -0
  3. package/dist/cjs/Mat33.test.d.ts +1 -0
  4. package/dist/cjs/Vec2.test.d.ts +1 -0
  5. package/dist/cjs/Vec3.test.d.ts +1 -0
  6. package/dist/cjs/polynomial/solveQuadratic.test.d.ts +1 -0
  7. package/dist/cjs/rounding.test.d.ts +1 -0
  8. package/dist/cjs/shapes/LineSegment2.test.d.ts +1 -0
  9. package/dist/cjs/shapes/Path.fromString.test.d.ts +1 -0
  10. package/dist/cjs/shapes/Path.test.d.ts +1 -0
  11. package/dist/cjs/shapes/Path.toString.test.d.ts +1 -0
  12. package/dist/cjs/shapes/QuadraticBezier.test.d.ts +1 -0
  13. package/dist/cjs/shapes/Rect2.test.d.ts +1 -0
  14. package/dist/cjs/shapes/Triangle.test.d.ts +1 -0
  15. package/dist/mjs/Color4.test.d.ts +1 -0
  16. package/dist/mjs/Mat33.test.d.ts +1 -0
  17. package/dist/mjs/Vec2.test.d.ts +1 -0
  18. package/dist/mjs/Vec3.test.d.ts +1 -0
  19. package/dist/mjs/polynomial/solveQuadratic.test.d.ts +1 -0
  20. package/dist/mjs/rounding.test.d.ts +1 -0
  21. package/dist/mjs/shapes/LineSegment2.test.d.ts +1 -0
  22. package/dist/mjs/shapes/Path.fromString.test.d.ts +1 -0
  23. package/dist/mjs/shapes/Path.test.d.ts +1 -0
  24. package/dist/mjs/shapes/Path.toString.test.d.ts +1 -0
  25. package/dist/mjs/shapes/QuadraticBezier.test.d.ts +1 -0
  26. package/dist/mjs/shapes/Rect2.test.d.ts +1 -0
  27. package/dist/mjs/shapes/Triangle.test.d.ts +1 -0
  28. package/dist-test/test_imports/package-lock.json +13 -0
  29. package/dist-test/test_imports/package.json +12 -0
  30. package/dist-test/test_imports/test-imports.js +15 -0
  31. package/dist-test/test_imports/test-require.cjs +15 -0
  32. package/package.json +4 -3
  33. package/src/Color4.test.ts +52 -0
  34. package/src/Color4.ts +318 -0
  35. package/src/Mat33.test.ts +244 -0
  36. package/src/Mat33.ts +450 -0
  37. package/src/Vec2.test.ts +30 -0
  38. package/src/Vec2.ts +49 -0
  39. package/src/Vec3.test.ts +51 -0
  40. package/src/Vec3.ts +245 -0
  41. package/src/lib.ts +42 -0
  42. package/src/polynomial/solveQuadratic.test.ts +39 -0
  43. package/src/polynomial/solveQuadratic.ts +43 -0
  44. package/src/rounding.test.ts +65 -0
  45. package/src/rounding.ts +167 -0
  46. package/src/shapes/Abstract2DShape.ts +63 -0
  47. package/src/shapes/BezierJSWrapper.ts +93 -0
  48. package/src/shapes/CubicBezier.ts +35 -0
  49. package/src/shapes/LineSegment2.test.ts +99 -0
  50. package/src/shapes/LineSegment2.ts +232 -0
  51. package/src/shapes/Path.fromString.test.ts +223 -0
  52. package/src/shapes/Path.test.ts +309 -0
  53. package/src/shapes/Path.toString.test.ts +77 -0
  54. package/src/shapes/Path.ts +963 -0
  55. package/src/shapes/PointShape2D.ts +33 -0
  56. package/src/shapes/QuadraticBezier.test.ts +31 -0
  57. package/src/shapes/QuadraticBezier.ts +142 -0
  58. package/src/shapes/Rect2.test.ts +209 -0
  59. package/src/shapes/Rect2.ts +346 -0
  60. package/src/shapes/Triangle.test.ts +61 -0
  61. package/src/shapes/Triangle.ts +139 -0
package/src/Vec2.ts ADDED
@@ -0,0 +1,49 @@
1
+ import Vec3 from './Vec3';
2
+
3
+ /**
4
+ * Utility functions that facilitate treating `Vec3`s as 2D vectors.
5
+ *
6
+ * @example
7
+ * ```ts,runnable,console
8
+ * import { Vec2 } from '@js-draw/math';
9
+ * console.log(Vec2.of(1, 2));
10
+ * ```
11
+ */
12
+ export namespace Vec2 {
13
+ /**
14
+ * Creates a `Vec2` from an x and y coordinate.
15
+ *
16
+ * For example,
17
+ * ```ts
18
+ * const v = Vec2.of(3, 4); // x=3, y=4.
19
+ * ```
20
+ */
21
+ export const of = (x: number, y: number): Vec2 => {
22
+ return Vec3.of(x, y, 0);
23
+ };
24
+
25
+ /**
26
+ * Creates a `Vec2` from an object containing x and y coordinates.
27
+ *
28
+ * For example,
29
+ * ```ts
30
+ * const v1 = Vec2.ofXY({ x: 3, y: 4.5 });
31
+ * const v2 = Vec2.ofXY({ x: -123.4, y: 1 });
32
+ * ```
33
+ */
34
+ export const ofXY = ({x, y}: { x: number, y: number }): Vec2 => {
35
+ return Vec3.of(x, y, 0);
36
+ };
37
+
38
+ /** A vector of length 1 in the X direction (→). */
39
+ export const unitX = Vec2.of(1, 0);
40
+
41
+ /** A vector of length 1 in the Y direction (↑). */
42
+ export const unitY = Vec2.of(0, 1);
43
+
44
+ /** The zero vector: A vector with x=0, y=0. */
45
+ export const zero = Vec2.of(0, 0);
46
+ }
47
+
48
+ export type Point2 = Vec3;
49
+ export type Vec2 = Vec3; // eslint-disable-line
@@ -0,0 +1,51 @@
1
+
2
+ import Vec3 from './Vec3';
3
+
4
+ describe('Vec3', () => {
5
+ it('.xy should contain the x and y components', () => {
6
+ const vec = Vec3.of(1, 2, 3);
7
+ expect(vec.xy).toMatchObject({
8
+ x: 1,
9
+ y: 2,
10
+ });
11
+ });
12
+
13
+ it('should be combinable with other vectors via .zip', () => {
14
+ const vec1 = Vec3.unitX;
15
+ const vec2 = Vec3.unitY;
16
+ expect(vec1.zip(vec2, Math.min)).objEq(Vec3.zero);
17
+ expect(vec1.zip(vec2, Math.max)).objEq(Vec3.of(1, 1, 0));
18
+ });
19
+
20
+ it('.cross should obey the right hand rule', () => {
21
+ const vec1 = Vec3.unitX;
22
+ const vec2 = Vec3.unitY;
23
+ expect(vec1.cross(vec2)).objEq(Vec3.unitZ);
24
+ expect(vec2.cross(vec1)).objEq(Vec3.unitZ.times(-1));
25
+ });
26
+
27
+ it('.orthog should return an orthogonal vector', () => {
28
+ expect(Vec3.unitZ.orthog().dot(Vec3.unitZ)).toBe(0);
29
+
30
+ // Should return some orthogonal vector, even if given the zero vector
31
+ expect(Vec3.zero.orthog().dot(Vec3.zero)).toBe(0);
32
+ });
33
+
34
+ it('.minus should return the difference between two vectors', () => {
35
+ expect(Vec3.of(1, 2, 3).minus(Vec3.of(4, 5, 6))).objEq(Vec3.of(1 - 4, 2 - 5, 3 - 6));
36
+ });
37
+
38
+ it('.orthog should return a unit vector', () => {
39
+ expect(Vec3.zero.orthog().magnitude()).toBe(1);
40
+ expect(Vec3.unitZ.orthog().magnitude()).toBe(1);
41
+ expect(Vec3.unitX.orthog().magnitude()).toBe(1);
42
+ expect(Vec3.unitY.orthog().magnitude()).toBe(1);
43
+ });
44
+
45
+ it('.normalizedOrZero should normalize the given vector or return zero', () => {
46
+ expect(Vec3.zero.normalizedOrZero()).objEq(Vec3.zero);
47
+ expect(Vec3.unitX.normalizedOrZero()).objEq(Vec3.unitX);
48
+ expect(Vec3.unitX.times(22).normalizedOrZero()).objEq(Vec3.unitX);
49
+ expect(Vec3.of(1, 1, 1).times(22).normalizedOrZero().length()).toBeCloseTo(1);
50
+ });
51
+ });
package/src/Vec3.ts ADDED
@@ -0,0 +1,245 @@
1
+
2
+
3
+ /**
4
+ * A vector with three components, $\begin{pmatrix} x \\ y \\ z \end{pmatrix}$.
5
+ * Can also be used to represent a two-component vector.
6
+ *
7
+ * A `Vec3` is immutable.
8
+ *
9
+ * @example
10
+ *
11
+ * ```ts,runnable,console
12
+ * import { Vec3 } from '@js-draw/math';
13
+ *
14
+ * console.log('Vector addition:', Vec3.of(1, 2, 3).plus(Vec3.of(0, 1, 0)));
15
+ * console.log('Scalar multiplication:', Vec3.of(1, 2, 3).times(2));
16
+ * console.log('Cross products:', Vec3.unitX.cross(Vec3.unitY));
17
+ * console.log('Magnitude:', Vec3.of(1, 2, 3).length(), 'or', Vec3.of(1, 2, 3).magnitude());
18
+ * console.log('Square Magnitude:', Vec3.of(1, 2, 3).magnitudeSquared());
19
+ * console.log('As an array:', Vec3.unitZ.asArray());
20
+ * ```
21
+ */
22
+ export class Vec3 {
23
+ private constructor(
24
+ public readonly x: number,
25
+ public readonly y: number,
26
+ public readonly z: number
27
+ ) {
28
+ }
29
+
30
+ /** Returns the x, y components of this. */
31
+ public get xy(): { x: number; y: number } {
32
+ // Useful for APIs that behave differently if .z is present.
33
+ return {
34
+ x: this.x,
35
+ y: this.y,
36
+ };
37
+ }
38
+
39
+ /** Construct a vector from three components. */
40
+ public static of(x: number, y: number, z: number): Vec3 {
41
+ return new Vec3(x, y, z);
42
+ }
43
+
44
+ /** Returns this' `idx`th component. For example, `Vec3.of(1, 2, 3).at(1) → 2`. */
45
+ public at(idx: number): number {
46
+ if (idx === 0) return this.x;
47
+ if (idx === 1) return this.y;
48
+ if (idx === 2) return this.z;
49
+
50
+ throw new Error(`${idx} out of bounds!`);
51
+ }
52
+
53
+ /** Alias for this.magnitude. */
54
+ public length(): number {
55
+ return this.magnitude();
56
+ }
57
+
58
+ public magnitude(): number {
59
+ return Math.sqrt(this.dot(this));
60
+ }
61
+
62
+ public magnitudeSquared(): number {
63
+ return this.dot(this);
64
+ }
65
+
66
+ /**
67
+ * Return this' angle in the XY plane (treats this as a Vec2).
68
+ *
69
+ * This is equivalent to `Math.atan2(vec.y, vec.x)`.
70
+ */
71
+ public angle(): number {
72
+ return Math.atan2(this.y, this.x);
73
+ }
74
+
75
+ /**
76
+ * Returns a unit vector in the same direction as this.
77
+ *
78
+ * If `this` has zero length, the resultant vector has `NaN` components.
79
+ */
80
+ public normalized(): Vec3 {
81
+ const norm = this.magnitude();
82
+ return Vec3.of(this.x / norm, this.y / norm, this.z / norm);
83
+ }
84
+
85
+ /**
86
+ * Like {@link normalized}, except returns zero if this has zero magnitude.
87
+ */
88
+ public normalizedOrZero(): Vec3 {
89
+ if (this.eq(Vec3.zero)) {
90
+ return Vec3.zero;
91
+ }
92
+
93
+ return this.normalized();
94
+ }
95
+
96
+ /** @returns A copy of `this` multiplied by a scalar. */
97
+ public times(c: number): Vec3 {
98
+ return Vec3.of(this.x * c, this.y * c, this.z * c);
99
+ }
100
+
101
+ public plus(v: Vec3): Vec3 {
102
+ return Vec3.of(this.x + v.x, this.y + v.y, this.z + v.z);
103
+ }
104
+
105
+ public minus(v: Vec3): Vec3 {
106
+ return Vec3.of(this.x - v.x, this.y - v.y, this.z - v.z);
107
+ }
108
+
109
+ public dot(other: Vec3): number {
110
+ return this.x * other.x + this.y * other.y + this.z * other.z;
111
+ }
112
+
113
+ public cross(other: Vec3): Vec3 {
114
+ // | i j k |
115
+ // | x1 y1 z1| = (i)(y1z2 - y2z1) - (j)(x1z2 - x2z1) + (k)(x1y2 - x2y1)
116
+ // | x2 y2 z2|
117
+ return Vec3.of(
118
+ this.y * other.z - other.y * this.z,
119
+ other.x * this.z - this.x * other.z,
120
+ this.x * other.y - other.x * this.y
121
+ );
122
+ }
123
+
124
+ /**
125
+ * If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise,
126
+ * if `other is a `number`, returns the result of scalar multiplication.
127
+ *
128
+ * @example
129
+ * ```
130
+ * Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18)
131
+ * ```
132
+ */
133
+ public scale(other: Vec3|number): Vec3 {
134
+ if (typeof other === 'number') {
135
+ return this.times(other);
136
+ }
137
+
138
+ return Vec3.of(
139
+ this.x * other.x,
140
+ this.y * other.y,
141
+ this.z * other.z,
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated
147
+ * 90 degrees counter-clockwise.
148
+ */
149
+ public orthog(): Vec3 {
150
+ // If parallel to the z-axis
151
+ if (this.dot(Vec3.unitX) === 0 && this.dot(Vec3.unitY) === 0) {
152
+ return this.dot(Vec3.unitX) === 0 ? Vec3.unitX : this.cross(Vec3.unitX).normalized();
153
+ }
154
+
155
+ return this.cross(Vec3.unitZ.times(-1)).normalized();
156
+ }
157
+
158
+ /** Returns this plus a vector of length `distance` in `direction`. */
159
+ public extend(distance: number, direction: Vec3): Vec3 {
160
+ return this.plus(direction.normalized().times(distance));
161
+ }
162
+
163
+ /** Returns a vector `fractionTo` of the way to target from this. */
164
+ public lerp(target: Vec3, fractionTo: number): Vec3 {
165
+ return this.times(1 - fractionTo).plus(target.times(fractionTo));
166
+ }
167
+
168
+ /**
169
+ * `zip` Maps a component of this and a corresponding component of
170
+ * `other` to a component of the output vector.
171
+ *
172
+ * @example
173
+ * ```
174
+ * const a = Vec3.of(1, 2, 3);
175
+ * const b = Vec3.of(0.5, 2.1, 2.9);
176
+ *
177
+ * const zipped = a.zip(b, (aComponent, bComponent) => {
178
+ * return Math.min(aComponent, bComponent);
179
+ * });
180
+ *
181
+ * console.log(zipped.toString()); // → Vec(0.5, 2, 2.9)
182
+ * ```
183
+ */
184
+ public zip(
185
+ other: Vec3, zip: (componentInThis: number, componentInOther: number)=> number
186
+ ): Vec3 {
187
+ return Vec3.of(
188
+ zip(other.x, this.x),
189
+ zip(other.y, this.y),
190
+ zip(other.z, this.z)
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Returns a vector with each component acted on by `fn`.
196
+ *
197
+ * @example
198
+ * ```
199
+ * console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4)
200
+ * ```
201
+ */
202
+ public map(fn: (component: number, index: number)=> number): Vec3 {
203
+ return Vec3.of(
204
+ fn(this.x, 0), fn(this.y, 1), fn(this.z, 2)
205
+ );
206
+ }
207
+
208
+ public asArray(): [ number, number, number ] {
209
+ return [this.x, this.y, this.z];
210
+ }
211
+
212
+ /**
213
+ * [fuzz] The maximum difference between two components for this and [other]
214
+ * to be considered equal.
215
+ *
216
+ * @example
217
+ * ```
218
+ * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 100); // → true
219
+ * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 0.1); // → false
220
+ * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 3); // → true
221
+ * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 3.01); // → true
222
+ * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 2.99); // → false
223
+ * ```
224
+ */
225
+ public eq(other: Vec3, fuzz: number = 1e-10): boolean {
226
+ for (let i = 0; i < 3; i++) {
227
+ if (Math.abs(other.at(i) - this.at(i)) > fuzz) {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ return true;
233
+ }
234
+
235
+ public toString(): string {
236
+ return `Vec(${this.x}, ${this.y}, ${this.z})`;
237
+ }
238
+
239
+
240
+ public static unitX = Vec3.of(1, 0, 0);
241
+ public static unitY = Vec3.of(0, 1, 0);
242
+ public static unitZ = Vec3.of(0, 0, 1);
243
+ public static zero = Vec3.of(0, 0, 0);
244
+ }
245
+ export default Vec3;
package/src/lib.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ```ts,runnable,console
3
+ * import { Vec2, Mat33, Rect2 } from '@js-draw/math';
4
+ *
5
+ * // Example: Rotate a vector 90 degrees about the z-axis
6
+ * const rotate90Degrees = Mat33.zRotation(Math.PI/2); // π/2 radians = 90 deg
7
+ * const moveUp = Mat33.translation(Vec2.of(1, 0));
8
+ * const moveUpThenRotate = rotate90Degrees.rightMul(moveUp);
9
+ * console.log(moveUpThenRotate.transformVec2(Vec2.of(1, 2)));
10
+ *
11
+ * // Example: Bounding box of some points
12
+ * console.log(Rect2.bboxOf([
13
+ * Vec2.of(1, 2), Vec2.of(3, 4), Vec2.of(-100, 1000),
14
+ * ]));
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ export { LineSegment2 } from './shapes/LineSegment2';
21
+ export {
22
+ Path,
23
+
24
+ PathCommandType,
25
+ PathCommand,
26
+ LinePathCommand,
27
+ MoveToPathCommand,
28
+ QuadraticBezierPathCommand,
29
+ CubicBezierPathCommand,
30
+ } from './shapes/Path';
31
+ export { Rect2 } from './shapes/Rect2';
32
+ export { QuadraticBezier } from './shapes/QuadraticBezier';
33
+
34
+ export { Mat33, Mat33Array } from './Mat33';
35
+ export { Point2, Vec2 } from './Vec2';
36
+ export { Vec3 } from './Vec3';
37
+ export { Color4 } from './Color4';
38
+ export { toRoundedString } from './rounding';
39
+
40
+
41
+ // Note: All above exports cannot use `export { default as ... } from "..."` because this
42
+ // breaks TypeDoc -- TypeDoc otherwise labels any imports of these classes as `default`.
@@ -0,0 +1,39 @@
1
+
2
+ import solveQuadratic from './solveQuadratic';
3
+
4
+ describe('solveQuadratic', () => {
5
+ it('should solve linear equations', () => {
6
+ expect(solveQuadratic(0, 1, 2)).toMatchObject([ -2, -2 ]);
7
+ expect(solveQuadratic(0, 0, 2)[0]).toBeNaN();
8
+ });
9
+
10
+ it('should return both solutions to quadratic equations', () => {
11
+ type TestCase = [[number, number, number], [number, number]];
12
+
13
+ const testCases: TestCase[] = [
14
+ [ [ 1, 0, 0 ], [ 0, 0 ] ],
15
+ [ [ 2, 0, 0 ], [ 0, 0 ] ],
16
+
17
+ [ [ 1, 0, -1 ], [ 1, -1 ] ],
18
+ [ [ 1, 0, -4 ], [ 2, -2 ] ],
19
+ [ [ 1, 0, 4 ], [ NaN, NaN ] ],
20
+
21
+ [ [ 1, 1, 0 ], [ 0, -1 ] ],
22
+ [ [ 1, 2, 0 ], [ 0, -2 ] ],
23
+
24
+ [ [ 1, 2, 1 ], [ -1, -1 ] ],
25
+ [ [ -9, 2, 1/3 ], [ 1/3, -1/9 ] ],
26
+ ];
27
+
28
+ for (const [ testCase, solution ] of testCases) {
29
+ const foundSolutions = solveQuadratic(...testCase);
30
+ for (let i = 0; i < 2; i++) {
31
+ if (isNaN(solution[i]) && isNaN(foundSolutions[i])) {
32
+ expect(foundSolutions[i]).toBeNaN();
33
+ } else {
34
+ expect(foundSolutions[i]).toBeCloseTo(solution[i]);
35
+ }
36
+ }
37
+ }
38
+ });
39
+ });
@@ -0,0 +1,43 @@
1
+
2
+ /**
3
+ * Solves an equation of the form ax² + bx + c = 0.
4
+ * The larger solution is returned first.
5
+ *
6
+ * If there are no solutions, returns `[NaN, NaN]`. If there is one solution,
7
+ * repeats the solution twice in the result.
8
+ */
9
+ const solveQuadratic = (a: number, b: number, c: number): [number, number] => {
10
+ // See also https://en.wikipedia.org/wiki/Quadratic_formula
11
+
12
+ if (a === 0) {
13
+ let solution;
14
+
15
+ if (b === 0) {
16
+ solution = c === 0 ? 0 : NaN;
17
+ } else {
18
+ // Then we have bx + c = 0
19
+ // which implies bx = -c.
20
+ // Thus, x = -c/b
21
+ solution = -c / b;
22
+ }
23
+
24
+ return [ solution, solution ];
25
+ }
26
+
27
+ const discriminant = b * b - 4 * a * c;
28
+
29
+ if (discriminant < 0) {
30
+ return [ NaN, NaN ];
31
+ }
32
+
33
+ const rootDiscriminant = Math.sqrt(discriminant);
34
+ const solution1 = (-b + rootDiscriminant) / (2 * a);
35
+ const solution2 = (-b - rootDiscriminant) / (2 * a);
36
+
37
+ if (solution1 > solution2) {
38
+ return [ solution1, solution2 ];
39
+ } else {
40
+ return [ solution2, solution1 ];
41
+ }
42
+ };
43
+ export default solveQuadratic;
@@ -0,0 +1,65 @@
1
+ import { cleanUpNumber, toRoundedString, toStringOfSamePrecision } from './rounding';
2
+
3
+ describe('toRoundedString', () => {
4
+ it('should round up numbers endings similar to .999999999999999', () => {
5
+ expect(toRoundedString(0.999999999)).toBe('1');
6
+ expect(toRoundedString(0.899999999)).toBe('.9');
7
+ expect(toRoundedString(9.999999999)).toBe('10');
8
+ expect(toRoundedString(-10.999999999)).toBe('-11');
9
+ });
10
+
11
+ it('should round up numbers similar to 10.999999998', () => {
12
+ expect(toRoundedString(10.999999998)).toBe('11');
13
+ });
14
+
15
+ it('should round strings with multiple digits after the ending decimal points', () => {
16
+ expect(toRoundedString(292.2 - 292.8)).toBe('-.6');
17
+ expect(toRoundedString(4.06425600000023)).toBe('4.064256');
18
+ });
19
+
20
+ it('should round down strings ending endings similar to .00000001', () => {
21
+ expect(toRoundedString(10.00000001)).toBe('10');
22
+ expect(toRoundedString(-30.00000001)).toBe('-30');
23
+ expect(toRoundedString(-14.20000000000002)).toBe('-14.2');
24
+ });
25
+
26
+ it('should not round numbers insufficiently close to the next', () => {
27
+ expect(toRoundedString(-10.9999)).toBe('-10.9999');
28
+ expect(toRoundedString(-10.0001)).toBe('-10.0001');
29
+ expect(toRoundedString(-10.123499)).toBe('-10.123499');
30
+ expect(toRoundedString(0.00123499)).toBe('.00123499');
31
+ });
32
+ });
33
+
34
+ it('toStringOfSamePrecision', () => {
35
+ expect(toStringOfSamePrecision(1.23456, '1.12')).toBe('1.23');
36
+ expect(toStringOfSamePrecision(1.23456, '1.120')).toBe('1.235');
37
+ expect(toStringOfSamePrecision(1.23456, '1.1')).toBe('1.2');
38
+ expect(toStringOfSamePrecision(1.23456, '1.1', '5.32')).toBe('1.23');
39
+ expect(toStringOfSamePrecision(-1.23456, '1.1', '5.32')).toBe('-1.23');
40
+ expect(toStringOfSamePrecision(-1.99999, '1.1', '5.32')).toBe('-2');
41
+ expect(toStringOfSamePrecision(1.99999, '1.1', '5.32')).toBe('2');
42
+ expect(toStringOfSamePrecision(1.89999, '1.1', '5.32')).toBe('1.9');
43
+ expect(toStringOfSamePrecision(9.99999999, '-1.1234')).toBe('10');
44
+ expect(toStringOfSamePrecision(9.999999998999996, '100')).toBe('10');
45
+ expect(toStringOfSamePrecision(0.000012345, '0.000012')).toBe('.000012');
46
+ expect(toStringOfSamePrecision(0.000012645, '.000012')).toBe('.000013');
47
+ expect(toStringOfSamePrecision(-0.09999999999999432, '291.3')).toBe('-.1');
48
+ expect(toStringOfSamePrecision(-0.9999999999999432, '291.3')).toBe('-1');
49
+ expect(toStringOfSamePrecision(9998.9, '.1', '-11')).toBe('9998.9');
50
+ expect(toStringOfSamePrecision(-14.20000000000002, '.000001', '-11')).toBe('-14.2');
51
+ });
52
+
53
+ it('cleanUpNumber', () => {
54
+ expect(cleanUpNumber('000.0000')).toBe('0');
55
+ expect(cleanUpNumber('-000.0000')).toBe('0');
56
+ expect(cleanUpNumber('0.0000')).toBe('0');
57
+ expect(cleanUpNumber('0.001')).toBe('.001');
58
+ expect(cleanUpNumber('-0.001')).toBe('-.001');
59
+ expect(cleanUpNumber('-0.000000001')).toBe('-.000000001');
60
+ expect(cleanUpNumber('-0.00000000100')).toBe('-.000000001');
61
+ expect(cleanUpNumber('1234')).toBe('1234');
62
+ expect(cleanUpNumber('1234.5')).toBe('1234.5');
63
+ expect(cleanUpNumber('1234.500')).toBe('1234.5');
64
+ expect(cleanUpNumber('1.1368683772161603e-13')).toBe('0');
65
+ });
@@ -0,0 +1,167 @@
1
+ // @packageDocumentation @internal
2
+
3
+ // Clean up stringified numbers
4
+ export const cleanUpNumber = (text: string) => {
5
+ // Regular expression substitions can be somewhat expensive. Only do them
6
+ // if necessary.
7
+
8
+ if (text.indexOf('e') > 0) {
9
+ // Round to zero.
10
+ if (text.match(/[eE][-]\d{2,}$/)) {
11
+ return '0';
12
+ }
13
+ }
14
+
15
+ const lastChar = text.charAt(text.length - 1);
16
+ if (lastChar === '0' || lastChar === '.') {
17
+ // Remove trailing zeroes
18
+ text = text.replace(/([.]\d*[^0]+)0+$/, '$1');
19
+ text = text.replace(/[.]0+$/, '.');
20
+
21
+ // Remove trailing period
22
+ text = text.replace(/[.]$/, '');
23
+ }
24
+
25
+ const firstChar = text.charAt(0);
26
+ if (firstChar === '0' || firstChar === '-') {
27
+ // Remove unnecessary leading zeroes.
28
+ text = text.replace(/^(0+)[.]/, '.');
29
+ text = text.replace(/^-(0+)[.]/, '-.');
30
+ text = text.replace(/^(-?)0+$/, '$10');
31
+ }
32
+
33
+ if (text === '-0') {
34
+ return '0';
35
+ }
36
+
37
+ return text;
38
+ };
39
+
40
+ /**
41
+ * Converts `num` to a string, removing trailing digits that were likely caused by
42
+ * precision errors.
43
+ *
44
+ * @example
45
+ * ```ts,runnable,console
46
+ * import { toRoundedString } from '@js-draw/math';
47
+ *
48
+ * console.log('Rounded: ', toRoundedString(1.000000011));
49
+ * ```
50
+ */
51
+ export const toRoundedString = (num: number): string => {
52
+ // Try to remove rounding errors. If the number ends in at least three/four zeroes
53
+ // (or nines) just one or two digits, it's probably a rounding error.
54
+ const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/;
55
+ const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/;
56
+
57
+ let text = num.toString(10);
58
+ if (text.indexOf('.') === -1) {
59
+ return text;
60
+ }
61
+
62
+ const roundingDownMatch = hasRoundingDownExp.exec(text);
63
+ if (roundingDownMatch) {
64
+ const negativeSign = roundingDownMatch[1];
65
+ const postDecimalString = roundingDownMatch[3];
66
+ const lastDigit = parseInt(postDecimalString.charAt(postDecimalString.length - 1), 10);
67
+ const postDecimal = parseInt(postDecimalString, 10);
68
+ const preDecimal = parseInt(roundingDownMatch[2], 10);
69
+
70
+ const origPostDecimalString = roundingDownMatch[3];
71
+
72
+ let newPostDecimal = (postDecimal + 10 - lastDigit).toString();
73
+ let carry = 0;
74
+ if (newPostDecimal.length > postDecimal.toString().length) {
75
+ // Left-shift
76
+ newPostDecimal = newPostDecimal.substring(1);
77
+ carry = 1;
78
+ }
79
+
80
+ // parseInt(...).toString() removes leading zeroes. Add them back.
81
+ while (newPostDecimal.length < origPostDecimalString.length) {
82
+ newPostDecimal = carry.toString(10) + newPostDecimal;
83
+ carry = 0;
84
+ }
85
+
86
+ text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`;
87
+ }
88
+
89
+ text = text.replace(fixRoundingUpExp, '$1');
90
+
91
+ return cleanUpNumber(text);
92
+ };
93
+
94
+ const numberExp = /^([-]?)(\d*)[.](\d+)$/;
95
+ export const getLenAfterDecimal = (numberAsString: string) => {
96
+ const numberMatch = numberExp.exec(numberAsString);
97
+ if (!numberMatch) {
98
+ // If not a match, either the number is exponential notation (or is something
99
+ // like NaN or Infinity)
100
+ if (numberAsString.search(/[eE]/) !== -1 || /^[a-zA-Z]+$/.exec(numberAsString)) {
101
+ return -1;
102
+ // Or it has no decimal point
103
+ } else {
104
+ return 0;
105
+ }
106
+ }
107
+
108
+ const afterDecimalLen = numberMatch[3].length;
109
+ return afterDecimalLen;
110
+ };
111
+
112
+ // [reference] should be a string representation of a base-10 number (no exponential (e.g. 10e10))
113
+ export const toStringOfSamePrecision = (num: number, ...references: string[]): string => {
114
+ const text = num.toString(10);
115
+ const textMatch = numberExp.exec(text);
116
+ if (!textMatch) {
117
+ return text;
118
+ }
119
+
120
+ let decimalPlaces = -1;
121
+ for (const reference of references) {
122
+ decimalPlaces = Math.max(getLenAfterDecimal(reference), decimalPlaces);
123
+ }
124
+
125
+ if (decimalPlaces === -1) {
126
+ return toRoundedString(num);
127
+ }
128
+
129
+ // Make text's after decimal length match [afterDecimalLen].
130
+ let postDecimal = textMatch[3].substring(0, decimalPlaces);
131
+ let preDecimal = textMatch[2];
132
+ const nextDigit = textMatch[3].charAt(decimalPlaces);
133
+
134
+ if (nextDigit !== '') {
135
+ const asNumber = parseInt(nextDigit, 10);
136
+ if (asNumber >= 5) {
137
+ // Don't attempt to parseInt() an empty string.
138
+ if (postDecimal.length > 0) {
139
+ const leadingZeroMatch = /^(0+)(\d*)$/.exec(postDecimal);
140
+
141
+ let leadingZeroes = '';
142
+ let postLeading = postDecimal;
143
+ if (leadingZeroMatch) {
144
+ leadingZeroes = leadingZeroMatch[1];
145
+ postLeading = leadingZeroMatch[2];
146
+ }
147
+
148
+ postDecimal = (parseInt(postDecimal) + 1).toString();
149
+
150
+ // If postDecimal got longer, remove leading zeroes if possible
151
+ if (postDecimal.length > postLeading.length && leadingZeroes.length > 0) {
152
+ leadingZeroes = leadingZeroes.substring(1);
153
+ }
154
+
155
+ postDecimal = leadingZeroes + postDecimal;
156
+ }
157
+
158
+ if (postDecimal.length === 0 || postDecimal.length > decimalPlaces) {
159
+ preDecimal = (parseInt(preDecimal) + 1).toString();
160
+ postDecimal = postDecimal.substring(1);
161
+ }
162
+ }
163
+ }
164
+
165
+ const negativeSign = textMatch[1];
166
+ return cleanUpNumber(`${negativeSign}${preDecimal}.${postDecimal}`);
167
+ };