@mathigon/fermat
Version:
Powerful mathematics and statistics library for JavaScript.
246 lines (197 loc) • 8.71 kB
text/typescript
// =============================================================================
// Fermat.js | Extended Number Class
// (c) Mathigon
// =============================================================================
import {isInteger, nearlyEquals, numberFormat, sign} from './arithmetic';
import {gcd, lcm} from './number-theory';
const FORMAT = /^([0-9\-.]*)([%πkmbtq]?)(\/([0-9\-.]+))?([%π]?)$/;
const tooBig = (x: number) => x >= Number.MAX_SAFE_INTEGER;
type Suffix = '%'|'π'|undefined;
/** Extended Number class. */
export class XNumber {
num: number; /** Used for all number types (decimals, fractions, units, ...). */
den?: number; /** Only used for fractions and always ≥ 0. */
constructor(num: number, den?: number, public unit?: Suffix) {
// Ensure that den is always positive
this.num = (den !== undefined && den < 0) ? -num : num;
if (den !== undefined && Math.abs(den) !== 1 && num !== 0) this.den = Math.abs(den);
}
valueOf() {
return this.value;
}
toMixed() {
if (!this.den || this.unit) return this.toString();
const part = Math.abs(this.num) % this.den;
const whole = Math.abs(Math.trunc(this.value));
if (!whole) return this.toString();
return `${this.sign < 0 ? '–' : ''}${whole} ${part}/${this.den}`;
}
toExpr(type?: 'decimal'|'fraction'|'mixed'|'scientific', precision = 4) {
const v = this.value;
// TODO Decide if we really want to return infinity here...
if (Math.abs(v) >= Number.MAX_VALUE) return '∞';
if (tooBig(this.num) || this.den && tooBig(this.den)) type = 'decimal';
// In scientific notation, we try to return a number in the form a × 10^b
if (type === 'scientific' || Math.abs(v) >= Number.MAX_SAFE_INTEGER) {
const [base, power] = this.value.toExponential(precision - 1).split('e');
if (Math.abs(+power) >= precision) {
const isNeg = power.startsWith('-');
const exp = `${isNeg ? '(' : ''}${isNeg ? power : power.slice(1)}${isNeg ? ')' : ''}`;
return `${base.replace(/\.?0+$/, '')} × 10^${exp}${this.unit || ''}`;
}
}
if ((!this.unit && !this.den) || type === 'decimal' || type === 'scientific') {
const formatted = numberFormat(this.value, precision);
// For non-standard number formatting, we add quotes for expr parsing.
return (formatted.match(/^[\d.]+$/g) ? formatted : `"${formatted}"`);
} else {
return type === 'mixed' ? this.toMixed() : this.toString();
}
}
toString(precision = 4) {
const separators = !this.den && !this.unit;
let num = numberFormat(this.num, this.den ? 0 : precision, separators);
let unit = this.unit || '';
const den = this.den ? `/${numberFormat(this.den, 0, separators)}` : '';
if (num === '0') unit = '';
if (unit === 'π' && !this.den && (num === '1' || num === '–1')) num = num.replace('1', '');
return `${num}${den}${unit}`;
}
toMathML() {
let str = `<mn>${this.num}</mn>`;
if (this.den !== undefined) str = `<mfrac>${str}<mn>${this.den}</mn></mfrac>`;
if (this.unit) str += (this.unit === 'π') ? `<mi>π</mi>` : `<mo>%</mo>`;
return str;
}
// ---------------------------------------------------------------------------
/**
* Returns the value of this number as a decimal. For example, 2/5 and 40%
* would both return 0.4.
*/
get value() {
const unit = (this.unit === '%') ? 1/100 : (this.unit === 'π') ? Math.PI : 1;
return this.num * unit / (this.den || 1);
}
get sign() {
return Math.sign(this.num);
}
/** Simplifies fractions, e.g. 4/8 would become 1/2. */
get simplified(): XNumber {
if (!this.den) return this;
const factor = gcd(Math.abs(this.num), this.den);
return new XNumber(this.num / factor, this.den / factor, this.unit);
}
/** Returns 1/x of this number. */
get inverse() {
if (!this.den) return new XNumber(this.den!, this.num);
return new XNumber(1 / this.num, undefined, this.unit);
}
/** Returns -x of this number. */
get negative() {
return new XNumber(-this.num, this.den, this.unit);
}
get fraction() {
if (this.unit || !isInteger(this.num)) return;
return [this.num, this.den || 1];
}
// ---------------------------------------------------------------------------
/** Parses a number string, e.g. '1/2' or '20.7%'. */
static fromString(s: string) {
s = s.toLowerCase().replace(/[\s,"]/g, '').replace('–', '-').replace('pi', 'π');
const match = s.match(FORMAT);
if (!match) return;
let suffix = (match[2] || match[5] || undefined) as Suffix;
let num = match[1] ? +match[1] : undefined;
const den = match[4] ? +match[4] : undefined;
// Special handling for π and -π
if (suffix === 'π' && (!match[1] || match[1] === '-')) num = match[1] ? -1 : 1;
if (num === undefined || isNaN(num)) return;
// Handle larger power suffixes
const power = suffix ? 'kmbtq'.indexOf(suffix) : -1;
if (power >= 0) {
num *= 1000 ** (power + 1);
suffix = undefined;
}
// Create XNumber instances
if (den === undefined) return new XNumber(num, undefined, suffix);
if (isNaN(den) || nearlyEquals(den, 0)) return;
if (!isInteger(num) || !isInteger(den)) return new XNumber(num / den, undefined, suffix);
return new XNumber(num, den, suffix);
}
/** Converts a decimal into the closest fraction with a given maximum denominator. */
static fractionFromDecimal(x: number, maxDen = 1000, precision = 1e-12) {
let n = [1, 0];
let d = [0, 1];
const absX = Math.abs(x);
let rem = absX;
while (Math.abs(n[0] / d[0] - absX) > precision) {
const a = Math.floor(rem);
n = [a * n[0] + n[1], n[0]];
d = [a * d[0] + d[1], d[0]];
if (d[0] > maxDen) return new XNumber(x);
rem = 1 / (rem - a);
}
if (!nearlyEquals(n[0] / d[0], absX, precision)) return new XNumber(x);
return new XNumber(sign(x) * n[0], d[0] === 1 ? undefined : d[0]);
}
// ---------------------------------------------------------------------------
clamp(min?: number, max?: number) {
const v = this.value;
if (min !== undefined && v < min) return new XNumber(min);
if (max !== undefined && v > max) return new XNumber(max);
return this;
}
add(a: XNumber|number) {
return XNumber.sum(this, a);
}
subtract(a: XNumber|number) {
return XNumber.difference(this, a);
}
multiply(a: XNumber|number) {
return XNumber.product(this, a);
}
divide(a: XNumber|number) {
return XNumber.quotient(this, a);
}
/** Calculates the sum of two fractions a and b. */
static sum(a: XNumber, b: XNumber|number): XNumber {
if (typeof b === 'number') b = new XNumber(b);
if (a.num === 0) return b;
// If units are different, always convert to a decimal
// TODO Maybe have special handling for fraction + percentage?
if (a.unit !== b.unit) return new XNumber(a.value + b.value);
// Neither a nor b are fractions
if (!a.den && !b.den) return new XNumber(a.num + b.num, undefined, a.unit);
// Ensure that a is always a fraction
if (!a.den) [a, b] = [b, a];
// Trying to add a decimal to a fraction.
// TODO Maybe try XNumber.fractionFromDecimal?
if (!isInteger(b.num)) return new XNumber(a.value + b.value, undefined, a.unit);
const common = lcm(a.den!, b.den ||1);
const fa = common / a.den!;
const fb = common / (b.den || 1);
return new XNumber(a.num * fa + b.num * fb, common, a.unit);
}
/** Calculates the difference of two numbers a and b. */
static difference(a: XNumber, b: XNumber|number) {
if (typeof b === 'number') b = new XNumber(b);
return XNumber.sum(a, b.negative);
}
/** Calculates the product of two numbers a and b. */
static product(a: XNumber, b: XNumber|number) {
if (typeof b === 'number') b = new XNumber(b);
// Handle simple integer multiplication
if (!a.unit && !a.den && isInteger(a.num)) return new XNumber(a.num * b.num, b.den, b.unit);
if (!b.unit && !b.den && isInteger(b.num)) return new XNumber(a.num * b.num, a.den, a.unit);
// Decimals or units that need to be converted
if (a.unit === 'π' || b.unit === 'π' || !isInteger(a.num) || !isInteger(b.num)) return new XNumber(a.value * b.value);
// Fraction multiplication
const units = (a.unit === '%' ? 100 : 1) * (b.unit === '%' ? 100 : 1);
return new XNumber(a.num * b.num, (a.den || 1) * (b.den || 1) * units);
}
/** Calculates the quotient of two fractions a and b. */
static quotient(a: XNumber, b: XNumber|number) {
if (typeof b === 'number') b = new XNumber(b);
return XNumber.product(a, b.inverse);
}
}