@js-draw/math 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cjs/Color4.test.d.ts +1 -0
- package/dist/cjs/Mat33.test.d.ts +1 -0
- package/dist/cjs/Vec2.test.d.ts +1 -0
- package/dist/cjs/Vec3.test.d.ts +1 -0
- package/dist/cjs/polynomial/solveQuadratic.test.d.ts +1 -0
- package/dist/cjs/rounding.test.d.ts +1 -0
- package/dist/cjs/shapes/LineSegment2.test.d.ts +1 -0
- package/dist/cjs/shapes/Path.fromString.test.d.ts +1 -0
- package/dist/cjs/shapes/Path.test.d.ts +1 -0
- package/dist/cjs/shapes/Path.toString.test.d.ts +1 -0
- package/dist/cjs/shapes/QuadraticBezier.test.d.ts +1 -0
- package/dist/cjs/shapes/Rect2.test.d.ts +1 -0
- package/dist/cjs/shapes/Triangle.test.d.ts +1 -0
- package/dist/mjs/Color4.test.d.ts +1 -0
- package/dist/mjs/Mat33.test.d.ts +1 -0
- package/dist/mjs/Vec2.test.d.ts +1 -0
- package/dist/mjs/Vec3.test.d.ts +1 -0
- package/dist/mjs/polynomial/solveQuadratic.test.d.ts +1 -0
- package/dist/mjs/rounding.test.d.ts +1 -0
- package/dist/mjs/shapes/LineSegment2.test.d.ts +1 -0
- package/dist/mjs/shapes/Path.fromString.test.d.ts +1 -0
- package/dist/mjs/shapes/Path.test.d.ts +1 -0
- package/dist/mjs/shapes/Path.toString.test.d.ts +1 -0
- package/dist/mjs/shapes/QuadraticBezier.test.d.ts +1 -0
- package/dist/mjs/shapes/Rect2.test.d.ts +1 -0
- package/dist/mjs/shapes/Triangle.test.d.ts +1 -0
- package/dist-test/test_imports/package-lock.json +13 -0
- package/dist-test/test_imports/package.json +12 -0
- package/dist-test/test_imports/test-imports.js +15 -0
- package/dist-test/test_imports/test-require.cjs +15 -0
- package/package.json +4 -3
- package/src/Color4.test.ts +52 -0
- package/src/Color4.ts +318 -0
- package/src/Mat33.test.ts +244 -0
- package/src/Mat33.ts +450 -0
- package/src/Vec2.test.ts +30 -0
- package/src/Vec2.ts +49 -0
- package/src/Vec3.test.ts +51 -0
- package/src/Vec3.ts +245 -0
- package/src/lib.ts +42 -0
- package/src/polynomial/solveQuadratic.test.ts +39 -0
- package/src/polynomial/solveQuadratic.ts +43 -0
- package/src/rounding.test.ts +65 -0
- package/src/rounding.ts +167 -0
- package/src/shapes/Abstract2DShape.ts +63 -0
- package/src/shapes/BezierJSWrapper.ts +93 -0
- package/src/shapes/CubicBezier.ts +35 -0
- package/src/shapes/LineSegment2.test.ts +99 -0
- package/src/shapes/LineSegment2.ts +232 -0
- package/src/shapes/Path.fromString.test.ts +223 -0
- package/src/shapes/Path.test.ts +309 -0
- package/src/shapes/Path.toString.test.ts +77 -0
- package/src/shapes/Path.ts +963 -0
- package/src/shapes/PointShape2D.ts +33 -0
- package/src/shapes/QuadraticBezier.test.ts +31 -0
- package/src/shapes/QuadraticBezier.ts +142 -0
- package/src/shapes/Rect2.test.ts +209 -0
- package/src/shapes/Rect2.ts +346 -0
- package/src/shapes/Triangle.test.ts +61 -0
- 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
|
package/src/Vec3.test.ts
ADDED
@@ -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
|
+
});
|
package/src/rounding.ts
ADDED
@@ -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
|
+
};
|