shelving
Version:
Toolkit for using data in JavaScript.
290 lines (289 loc) • 11.5 kB
JavaScript
import { RequiredError } from "../error/RequiredError.js";
import { ValueError } from "../error/ValueError.js";
import { DAY, HOUR, MILLION, MINUTE, MONTH, NNBSP, SECOND, WEEK, YEAR } from "./constants.js";
import { formatUnit } from "./format.js";
import { ImmutableMap } from "./map.js";
import { getProps } from "./object.js";
/** Convert an amount using a `Conversion. */
function _convert(amount, conversion) {
return typeof conversion === "function" ? conversion(amount) : conversion === 1 ? amount : amount * conversion;
}
/** Represent a unit. */
export class Unit {
_to;
/** `UnitList` this unit belongs to. */
list;
/** String key for this unit, e.g. `kilometer` */
key;
/** Possible options for formatting these units. */
options;
constructor(
/** `UnitList` this unit belongs to. */
list,
/** String key for this unit, e.g. `kilometer` */
key,
/** Props to configure this unit. */
{ to, ...options }) {
this.list = list;
this.key = key;
this.options = options;
this._to = to;
}
/** Convert an amount from this unit to another unit. */
to(amount, targetKey) {
const target = targetKey ? _requireUnit(this.to, this.list, targetKey) : this.list.base;
return this._convertTo(amount, target, this.to);
}
/** Convert an amount from another unit to this unit. */
from(amount, sourceKey) {
const source = sourceKey ? _requireUnit(this.from, this.list, sourceKey) : this.list.base;
return source._convertTo(amount, this, this.from);
}
/** Convert an amount from this unit to another unit (must specify another `Unit` instance). */
_convertTo(amount, target, caller) {
// No conversion.
if (target === this)
return amount;
// Exact conversion.
// When this unit knows the multiplier or function to convert to the target unit.
const thisToUnit = this._to?.[target.key];
if (thisToUnit)
return _convert(amount, thisToUnit);
// Invert number conversion.
// This is where the target type knows the multiplier to convert to this.
// Can't do this for function conversions.
const unitToThis = target._to?.[this.key];
if (typeof unitToThis === "number")
return amount / unitToThis;
// Via base conversion.
// Everything should know how to convert to its base units.
const base = this.list.base;
const thisToBase = this._to?.[base.key];
if (thisToBase)
return base._convertTo(_convert(amount, thisToBase), target, caller);
// Not convertable.
throw new ValueError(`Cannot convert "${base.key}" to "${this.key}"`, { list: this, caller });
}
/**
* Format an amount with a given unit of measure, e.g. `12 kg` or `29.5 l`
* - Uses `Intl.NumberFormat` if this is a supported unit (so e.g. `ounce` is translated to e.g. `Unze` in German).
* - Polyfills unsupported units to use long/short form based on `options.unitDisplay`.
*/
format(amount, options) {
return formatUnit(amount, this.key, { ...this.options, ...options });
}
}
/**
* Represent a list of units.
* - Has a known base unit at `.base`
* - Can get required units from `.unit()`
* - Cannot have additional units added after it is created.
*/
export class UnitList extends ImmutableMap {
base;
constructor(units) {
super();
for (const [id, props] of getProps(units)) {
const unit = new Unit(this, id, props);
if (!this.base)
this.base = unit;
Map.prototype.set.call(this, id, unit);
}
}
/** Convert an amount from a unit to another unit. */
convert(amount, sourceKey, targetKey) {
return _requireUnit(this.convert, this, sourceKey).to(amount, targetKey);
}
/**
* Require a unit from this list.
* @throws RequiredError if the unit key is not found.
*/
require(key) {
return _requireUnit(this.require, this, key);
}
}
function _requireUnit(caller, list, key) {
const unit = list.get(key);
if (!unit)
throw new RequiredError(`Unknown unit "${key}"`, { key, list, caller });
return unit;
}
// Distance constants.
const IN_PER_FT = 12;
const IN_PER_YD = 36;
const IN_PER_MI = 63360;
const FT_PER_YD = 3;
const FT_PER_MI = 5280;
const YD_PER_MI = 1760;
const YD_PER_FUR = 220;
const MM_PER_CM = 10;
const MM_PER_M = 1000;
const MM_PER_KM = MILLION;
const MM_PER_IN = 25.4;
const MM_PER_MI = 1609344;
// Mass constants.
const MG_PER_LB = 453592.37;
const OZ_PER_LB = 16;
const LB_PER_ST = 14;
// Area constants.
const MM2_PER_IN2 = MM_PER_IN ** 2;
const FT2_PER_ACRE = 66 * 660;
const YD2_PER_ACRE = 22 * 220;
// Volume constants.
const MM3_PER_IN3 = MM_PER_IN ** 3;
const ML_PER_IN3 = MM3_PER_IN3 / 1000;
const US_IN3_PER_GAL = 231;
const IMP_ML_PER_GAL = 4546090 / 1000;
/** Percentage units. */
export const PERCENT_UNITS = new UnitList({
percent: { abbr: "%", many: "percent" },
});
/** Point units. */
export const POINT_UNITS = new UnitList({
"basis-point": { abbr: "bp" },
"percentage-point": { abbr: "pp", to: { "basis-point": 100 } },
});
/** Angle units. */
export const ANGLE_UNITS = new UnitList({
degree: { abbr: "deg" },
radian: { abbr: "rad", to: { degree: 180 / Math.PI } },
gradian: { abbr: "grad", to: { degree: 180 / 200 } },
});
/** Mass units. */
export const MASS_UNITS = new UnitList({
// Metric.
milligram: { abbr: "mg" },
gram: { abbr: "g", to: { milligram: 1000 } },
kilogram: { abbr: "kg", to: { milligram: MILLION } },
// Imperial.
ounce: { abbr: "oz", to: { milligram: MG_PER_LB / OZ_PER_LB } },
pound: { abbr: "lb", to: { milligram: MG_PER_LB, ounce: OZ_PER_LB } },
stone: { abbr: "st", many: "stone", to: { milligram: MG_PER_LB * LB_PER_ST, pound: LB_PER_ST, ounce: OZ_PER_LB * LB_PER_ST } },
});
const TIME_OPTIONS = {
roundingMode: "trunc",
maximumFractionDigits: 0,
};
/** Time units. */
export const TIME_UNITS = new UnitList({
millisecond: { ...TIME_OPTIONS, abbr: "ms" },
second: { ...TIME_OPTIONS, to: { millisecond: SECOND } },
minute: { ...TIME_OPTIONS, to: { millisecond: MINUTE } },
hour: { ...TIME_OPTIONS, to: { millisecond: HOUR } },
day: { ...TIME_OPTIONS, to: { millisecond: DAY } },
week: { ...TIME_OPTIONS, to: { millisecond: WEEK } },
month: { ...TIME_OPTIONS, to: { millisecond: MONTH } },
year: { ...TIME_OPTIONS, to: { millisecond: YEAR } },
});
/** Length units. */
export const LENGTH_UNITS = new UnitList({
// Metric.
millimeter: { abbr: "mm" },
centimeter: { abbr: "cm", to: { millimeter: MM_PER_CM } },
meter: { to: { millimeter: MM_PER_M } },
kilometer: { abbr: "km", to: { millimeter: MM_PER_KM } },
// Imperial.
inch: { abbr: "in", many: "inches", to: { millimeter: MM_PER_IN } },
foot: { abbr: "ft", many: "feet", to: { millimeter: IN_PER_FT * MM_PER_IN, inch: IN_PER_FT } },
yard: { abbr: "yd", to: { millimeter: IN_PER_YD * MM_PER_IN, inch: IN_PER_YD, foot: FT_PER_YD } },
furlong: { abbr: "fur", to: { millimeter: IN_PER_YD * MM_PER_IN * YD_PER_FUR, foot: YD_PER_FUR * FT_PER_YD, yard: YD_PER_FUR } },
mile: { abbr: "mi", to: { millimeter: MM_PER_MI, yard: YD_PER_MI, foot: FT_PER_MI, inch: IN_PER_MI } },
});
/** Speed units. */
export const SPEED_UNITS = new UnitList({
// Metric.
"meter-per-second": { abbr: "m/s", one: "meter per second", many: "meters per second", to: { "kilometer-per-hour": 3.6 } },
"kilometer-per-hour": {
abbr: "kph",
one: "kilometer per hour",
many: "kilometers per hour",
to: { "meter-per-second": MM_PER_KM / HOUR },
},
// Imperial.
"mile-per-hour": { abbr: "mph", one: "mile per hour", many: "miles per hour", to: { "meter-per-second": MM_PER_MI / HOUR } },
});
/** Area units. */
export const AREA_UNITS = new UnitList({
// Metric.
"square-millimeter": { abbr: "mm²" },
"square-centimeter": { abbr: "cm²", to: { "square-millimeter": MM_PER_CM ** 2 } },
"square-meter": { abbr: "m²", to: { "square-millimeter": MM_PER_M ** 2 } },
"square-kilometer": { abbr: "km²", to: { "square-millimeter": MM_PER_KM ** 2 } },
hectare: { abbr: "ha", to: { "square-millimeter": (MM_PER_M * 100) ** 2 } },
// Imperial.
"square-inch": { abbr: "in²", many: "square inches", to: { "square-millimeter": MM2_PER_IN2 } },
"square-foot": {
abbr: "ft²",
many: "square feet",
to: { "square-millimeter": IN_PER_FT ** 2 * MM2_PER_IN2, "square-inch": IN_PER_FT ** 2 },
},
"square-yard": {
abbr: "yd²",
to: { "square-millimeter": IN_PER_YD ** 2 * MM2_PER_IN2, "square-foot": FT_PER_YD ** 2, "square-inch": IN_PER_YD ** 2 },
},
acre: {
abbr: "acre",
to: { "square-millimeter": IN_PER_YD ** 2 * YD2_PER_ACRE * MM2_PER_IN2, "square-foot": FT2_PER_ACRE, "square-yard": YD2_PER_ACRE },
},
});
/** Volume units. */
export const VOLUME_UNITS = new UnitList({
// Metric.
milliliter: { abbr: "ml" },
liter: { abbr: "ltr", to: { milliliter: 1000 } },
"cubic-centimeter": { abbr: "cm³", to: { milliliter: 1 } },
"cubic-meter": { abbr: "m³", to: { milliliter: MILLION } },
// US.
"us-fluid-ounce": {
abbr: `fl${NNBSP}oz`,
one: "US fluid ounce",
many: "US fluid ounces",
to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 128 },
},
"us-pint": { abbr: "pt", one: "US pint", to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 8, "us-fluid-ounce": 16 } },
"us-quart": {
abbr: "qt",
one: "US quart",
to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 4, "us-pint": 2, "us-fluid-ounce": 32 },
},
"us-gallon": {
abbr: "gal",
one: "US gallon",
to: { milliliter: US_IN3_PER_GAL * ML_PER_IN3, "us-quart": 4, "us-pint": 8, "us-fluid-ounce": 128 },
},
// Imperial.
"imperial-fluid-ounce": { abbr: `fl${NNBSP}oz`, to: { milliliter: IMP_ML_PER_GAL / 160 } },
"imperial-pint": { abbr: "pt", to: { milliliter: IMP_ML_PER_GAL / 8, "imperial-fluid-ounce": 20 } },
"imperial-quart": { abbr: "qt", to: { milliliter: IMP_ML_PER_GAL / 4, "imperial-pint": 2, "imperial-fluid-ounce": 40 } },
"imperial-gallon": {
abbr: "gal",
to: { milliliter: IMP_ML_PER_GAL, "imperial-quart": 4, "imperial-pint": 8, "imperial-fluid-ounce": 160 },
},
"cubic-inch": { abbr: "in³", many: "cubic inches", to: { milliliter: ML_PER_IN3 } },
"cubic-foot": { abbr: "ft³", many: "cubic feet", to: { milliliter: IN_PER_FT ** 3 * ML_PER_IN3, "cubic-inch": IN_PER_FT ** 3 } },
"cubic-yard": {
abbr: "yd³",
to: { milliliter: IN_PER_YD ** 3 * ML_PER_IN3, "cubic-foot": FT_PER_YD ** 3, "cubic-inch": IN_PER_YD ** 3 },
},
});
/** Temperature units. */
export const TEMPERATURE_UNITS = new UnitList({
celsius: {
abbr: "°C",
one: "degree Celsius",
many: "degrees Celsius",
to: { fahrenheit: n => n * (9 / 5) + 32, kelvin: n => n + 273.15 },
},
fahrenheit: {
abbr: "°F",
one: "degree Fahrenheit",
many: "degrees Fahrenheit",
to: { celsius: n => (n - 32) * (5 / 9) },
},
kelvin: {
abbr: "°K",
one: "degree Kelvin",
many: "degrees Kelvin",
to: { celsius: n => n - 273.15 },
},
});