UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

298 lines (297 loc) 12.8 kB
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 { formatNumber, formatQuantity, pluralizeQuantity } 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; /** Short abbreviation for this unit, e.g. `km` (defaults to first letter of `id`). */ abbr; /** Singular name for this unit, e.g. `kilometer` (defaults to `id`). */ singular; /** Plural name for this unit, e.g. `kilometers` (defaults to `singular` + "s"). */ plural; /** Possible options for formatting these units with `Intl.NumberFormat` (`.unit` can be specified if different from key, but is not required). */ options; /** Title for this unit (uses format `abbr (plural)`, e.g. `fl oz (US fluid ounces)`) */ get title() { return `${this.abbr} (${this.plural})`; } constructor( /** `UnitList` this unit belongs to. */ list, /** String key for this unit, e.g. `kilometer` */ key, /** Props to configure this unit. */ { abbr = key.slice(0, 1), singular = key.replace(/-/, " "), plural = `${singular}s`, to }) { this.list = list; this.key = key; this.abbr = abbr; this.singular = singular; this.plural = plural; 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) { // If possible use `Intl` so that the user's locale is used. if (Intl.supportedValuesOf("unit").includes(this.key)) return formatNumber(amount, { style: "unit", unitDisplay: "short", ...this.options, ...options, unit: this.key }); // Otherwise, use the default number format. // If unitDisplay is "long" use the singular/plural form. const o = { style: "decimal", unitDisplay: "short", ...this.options, ...options }; return o.unitDisplay === "long" ? pluralizeQuantity(amount, this.singular, this.plural, o) : formatQuantity(amount, this.abbr, o); } } /** * 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: "%", plural: "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", plural: "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: { abbr: "ms", options: TIME_OPTIONS }, second: { to: { millisecond: SECOND }, options: TIME_OPTIONS }, minute: { to: { millisecond: MINUTE }, options: TIME_OPTIONS }, hour: { to: { millisecond: HOUR }, options: TIME_OPTIONS }, day: { to: { millisecond: DAY }, options: TIME_OPTIONS }, week: { to: { millisecond: WEEK }, options: TIME_OPTIONS }, month: { to: { millisecond: MONTH }, options: TIME_OPTIONS }, year: { to: { millisecond: YEAR }, options: TIME_OPTIONS }, }); /** 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", plural: "inches", to: { millimeter: MM_PER_IN } }, foot: { abbr: "ft", plural: "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", singular: "meter per second", plural: "meters per second", to: { "kilometer-per-hour": 3.6 } }, "kilometer-per-hour": { abbr: "kph", singular: "kilometer per hour", plural: "kilometers per hour", to: { "meter-per-second": MM_PER_KM / HOUR }, }, // Imperial. "mile-per-hour": { abbr: "mph", singular: "mile per hour", plural: "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²", plural: "square inches", to: { "square-millimeter": MM2_PER_IN2 } }, "square-foot": { abbr: "ft²", plural: "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`, singular: "US fluid ounce", plural: "US fluid ounces", to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 128 }, }, "us-pint": { abbr: "pt", singular: "US pint", to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 8, "us-fluid-ounce": 16 } }, "us-quart": { abbr: "qt", singular: "US quart", to: { milliliter: (US_IN3_PER_GAL * ML_PER_IN3) / 4, "us-pint": 2, "us-fluid-ounce": 32 }, }, "us-gallon": { abbr: "gal", singular: "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³", plural: "cubic inches", to: { milliliter: ML_PER_IN3 } }, "cubic-foot": { abbr: "ft³", plural: "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", singular: "degree Celsius", plural: "degrees Celsius", to: { fahrenheit: n => n * (9 / 5) + 32, kelvin: n => n + 273.15 }, }, fahrenheit: { abbr: "°F", singular: "degree Fahrenheit", plural: "degrees Fahrenheit", to: { celsius: n => (n - 32) * (5 / 9) } }, kelvin: { abbr: "°K", singular: "degree Kelvin", plural: "degrees Kelvin", to: { celsius: n => n - 273.15 } }, });