Source

Measurement.js

/*
 * Measurement.js - Measurement unit superclass
 *
 * Copyright © 2014-2015, 2018, 2021, 2023 JEDLSoft
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

// !depends JSUtils.js MathUtils.js Locale.js

var JSUtils = require("./JSUtils.js");
var MathUtils = require("./MathUtils.js");
var Locale = require("./Locale.js");

function round(number, precision) {
    var factor = Math.pow(10, precision);
    return MathUtils.halfdown(number * factor) / factor;
}

/**
 * @class
 * Superclass for measurement instances that contains shared functionality
 * and defines the interface. <p>
 *
 * This class is never instantiated on its own. Instead, measurements should
 * be created using the {@link MeasurementFactory} function, which creates the
 * correct subclass based on the given parameters.<p>
 *
 * @param {Object=} options options controlling the construction of this instance
 * @protected
 * @constructor
 */
var Measurement = function(options) {
    if (options) {
        if (typeof(options.unit) !== 'undefined') {
            this.originalUnit = options.unit;
            this.unit = this.normalizeUnits(options.unit) || options.unit;
        }

        if (typeof(options.amount) === 'object') {
            if (options.amount.getMeasure() === this.getMeasure()) {
                this.amount = options.amount.convert(this.unit);
            } else {
                throw "Cannot convert unit " + options.amount.unit + " to a " + this.getMeasure();
            }
        } else if (typeof(options.amount) !== 'undefined') {
            this.amount = Number(options.amount);
        }

        if (typeof(this.ratios[this.unit]) === 'undefined') {
            throw "Unknown unit: " + options.unit;
        }
    }
};

/**
 * @private
 */
Measurement._constructors = {};

Measurement.prototype = {
    /**
     * Return the normalized name of the given units. If the units are
     * not recognized, this method returns its parameter unmodified.<p>
     *
     * Examples:
     *
     * <ui>
     * <li>"metres" gets normalized to "meter"<br>
     * <li>"ml" gets normalized to "milliliter"<br>
     * <li>"foobar" gets normalized to "foobar" (no change because it is not recognized)
     * </ul>
     *
     * @param {string} name name of the units to normalize.
     * @returns {string} normalized name of the units
     */
    normalizeUnits: function(name) {
        return (this.constructor && (Measurement.getUnitId(this.constructor, name) ||
            Measurement.getUnitIdCaseInsensitive(this.constructor, name))) ||
            name;
    },

    /**
     * Return the normalized units used in this measurement.
     * @return {string} name of the unit of measurement
     */
    getUnit: function() {
        return this.unit;
    },

    /**
     * Return the units originally used to construct this measurement
     * before it was normalized.
     * @return {string} name of the unit of measurement
     */
    getOriginalUnit: function() {
        return this.originalUnit;
    },

    /**
     * Return the numeric amount of this measurement.
     * @return {number} the numeric amount of this measurement
     */
    getAmount: function() {
        return this.amount;
    },

    /**
     * Return the type of this measurement. Examples are "mass",
     * "length", "speed", etc. Measurements can only be converted
     * to measurements of the same type.<p>
     *
     * The type of the units is determined automatically from the
     * units. For example, the unit "grams" is type "mass". Use the
     * static call {@link Measurement.getAvailableUnits}
     * to find out what units this version of ilib supports.
     *
     * @return {string} the name of the type of this measurement
     */
    getMeasure: function() {},

    /**
     * Return an array of all units that this measurement types supports.
     *
     * @return {Array.<string>} an array of all units that this measurement
     * types supports
     */
    getMeasures: function () {
        return Object.keys(this.ratios);
    },

    /**
     * Return the name of the measurement system that the current
     * unit is a part of.
     *
     * @returns {string} the name of the measurement system for
     * the units of this measurement
     */
    getMeasurementSystem: function() {
        if (this.unit) {
            if (JSUtils.indexOf(this.systems.uscustomary, this.unit) > -1) {
                return "uscustomary";
            }

            if (JSUtils.indexOf(this.systems.imperial, this.unit) > -1) {
                return "imperial";
            }
        }
        return "metric";
    },

    /**
     * Localize the measurement to the commonly used measurement in that locale. For example
     * If a user's locale is "en-US" and the measurement is given as "60 kmh",
     * the formatted number should be automatically converted to the most appropriate
     * measure in the other system, in this case, mph. The formatted result should
     * appear as "37.3 mph".
     *
     * @param {string} locale current locale string
     * @returns {Measurement} a new instance that is converted to locale
     */
    localize: function(locale) {
        var to;
        var toSystem = Measurement.getMeasurementSystemForLocale(locale);
        var fromSystem = this.getMeasurementSystem();
        if (toSystem === fromSystem) return this; // already there
        to = this.systems.conversions[fromSystem] &&
            this.systems.conversions[fromSystem][toSystem] &&
            this.systems.conversions[fromSystem][toSystem][this.unit];

        return to ? this.newUnit({
            unit: to,
            amount: this.convert(to)
        }) : this;
    },

    /**
     * Return the amount of the current measurement when converted to the
     * given measurement unit. Measurements can only be converted
     * to other measurements of the same type.<p>
     *
     * @param {string} to the name of the units to convert this measurement to
     * @return {number|undefined} the amount corresponding to the requested unit
     */
    convert: function(to) {
        if (!to || typeof(this.ratios[this.normalizeUnits(to)]) === 'undefined') {
            return undefined;
        }

        var from = this.getUnitIdCaseInsensitive(this.unit) || this.unit;
        to = this.getUnitIdCaseInsensitive(to) || to;
        if (typeof(from) === 'undefined' || typeof(to) === 'undefined') {
            return undefined;
        }

        var fromRow = this.ratios[from];
        var toRow = this.ratios[to];
        return this.amount * fromRow[toRow[0]];
    },

    /**
     * Return a new measurement instance that is converted to a different
     * measurement system. Measurements can only be converted
     * to other measurements of the same type.<p>
     *
     * @param {string} measurementSystem the name of the system to convert to
     * @return {Measurement} a new measurement in the given system, or the
     * current measurement if it is already in the given system or could not
     * be converted
     */
    convertSystem: function(measurementSystem) {
        if (!measurementSystem || measurementSystem === this.getMeasurementSystem()) {
            return this;
        }
        var map = this.systems.conversions[this.getMeasurementSystem()][measurementSystem];
        var newunit = map && map[this.unit];
        if (!newunit) return this;

        return this.newUnit({
            unit: newunit,
            amount: this.convert(newunit)
        });
    },

    /**
     * Scale the measurement unit to an acceptable level. The scaling
     * happens so that the integer part of the amount is as small as
     * possible without being below zero. This will result in the
     * largest units that can represent this measurement without
     * fractions. Measurements can only be scaled to other measurements
     * of the same type.
     *
     * @param {string=} measurementsystem the name of the system to scale to
     * @param {Object=} units mapping from the measurement system to the units to use
     * for this scaling. If this is not defined, this measurement type will use the
     * set of units that it knows about for the given measurement system
     * @return {Measurement} a new instance that is scaled to the
     * right level
     */
    scale: function(measurementsystem, units) {
        var systemName = this.getMeasurementSystem();
        var mSystem;
        if (units) {
            mSystem = (units[measurementsystem] && JSUtils.indexOf(units[measurementsystem], this.unit) > -1) ?
                units[measurementsystem] : units[systemName];
        }
        if (!mSystem) {
            mSystem = (this.systems[measurementsystem] && JSUtils.indexOf(this.systems[measurementsystem], this.unit) > -1) ?
                this.systems[measurementsystem] : this.systems[systemName];
        }
        if (!mSystem) {
            // cannot find the system to scale within... just return the measurement as is
            return this;
        }

        return this.newUnit(this.scaleUnits(mSystem));
    },

    /**
     * Expand the current measurement such that any fractions of the current unit
     * are represented in terms of smaller units in the same system instead of fractions
     * of the current unit. For example, "6.25 feet" may be represented as
     * "6 feet 4 inches" instead. The return value is an array of measurements which
     * are progressively smaller until the smallest unit in the system is reached
     * or until there is a whole number of any unit along the way.
     *
     * @param {string=} measurementsystem system to use (uscustomary|imperial|metric),
     * or undefined if the system can be inferred from the current measure
     * @param {Array.<string>=} units object containing a mapping between the measurement system
     * and an array of units to use to restrict the expansion to
     * @param {function(number):number=} constrain a function that constrains
     * a number according to the display options
     * @param {boolean=} scale if true, rescale all of the units so that the
     * largest unit is the largest one with a non-fractional number. If false, then
     * the current unit stays the largest unit.
     * @return {Array.<Measurement>} an array of new measurements in order from
     * the current units to the smallest units in the system which together are the
     * same measurement as this one
     */
    expand: function(measurementsystem, units, constrain, scale) {
        var systemName = this.getMeasurementSystem();
        var mSystem = (units && units[systemName]) ? units[systemName] : (this.systems[systemName] || this.systems.metric);

        return this.list(mSystem, this.ratios, constrain, scale).map(function(item) {
            return this.newUnit(item);
        }.bind(this));
    },

    /**
     * Convert the current measurement to a list of measures
     * and amounts. This method will autoScale the current measurement
     * to the largest measure in the given measures list such that the
     * amount of that measure is still greater than or equal to 1. From
     * there, it will truncate that measure to a whole
     * number and then it will calculate the remainder in terms of
     * each of the smaller measures in the given list.<p>
     *
     * For example, if a person's height is given as 70.5 inches, and
     * the list of measures is ["mile", "foot", "inch"], then it will
     * scale the amount to 5 feet, 10.5 inches. The amount is not big
     * enough to have any whole miles, so that measure is not used.
     * The first measure will be "foot" because it is the first one
     * in the measure list where the there is an amount of them that
     * is greater than or equal to 1. The return value in this example
     * would be:
     *
     * <pre>
     * [
     *   {
     *     "unit": "foot",
     *     "amount": 5
     *   },
     *   {
     *     "unit": "inch",
     *     "amount": 10.5
     *   }
     * ]
     * </pre>
     *
     * Note that all measures except the smallest will be returned
     * as whole numbers. The smallest measure will contain any possible
     * fractional remainder.
     *
     * @param {Array.<string>|undefined} measures array of measure names to
     * convert this measure to
     * @param {Object} ratios the conversion ratios
     * table for the measurement type
     * @param {function (number): number=} constrain a function that constrains
     * a number according to the display options
     * @param {boolean=} scale if true, rescale all of the units so that the
     * largest unit is the largest one with a non-fractional number. If false, then
     * the current unit stays the largest unit.
     * @returns {Array.<{unit: String, amount: Number}>} the conversion
     * of the current measurement into an array of unit names and
     * their amounts
     */
    list: function(measures, ratios, constrain, scale) {
        var row = ratios[this.unit];
        var ret = [];
        var scaled;
        var unit = this.unit;
        var amount = this.amount;
        constrain = constrain || round;

        var start = JSUtils.indexOf(measures, this.unit);

        if (scale || start === -1) {
            start = measures.length-1;
        }

        if (this.unit !== measures[0]) {
            // if this unit is not the smallest measure in the system, we have to convert
            unit = measures[0];
            amount = this.amount * row[ratios[unit][0]];
            row = ratios[unit];
        }

        // convert to smallest measure
        amount = constrain(amount);
        // go backwards so we get from the largest to the smallest units in order
        for (var j = start; j > 0; j--) {
            unit = measures[j];
            scaled = amount * row[ratios[unit][0]];
            var xf = Math.floor(scaled);
            if (xf) {
                var item = {
                    unit: unit,
                    amount: xf
                };
                ret.push(item);

                amount -= xf * ratios[unit][ratios[measures[0]][0]];
            }
        }

        // last measure is rounded/constrained, not truncated
        if (amount !== 0) {
            ret.push({
                unit: measures[0],
                amount: constrain(amount)
            });
        }

        return ret;
    },

    /**
     * @private
     */
    scaleUnits: function(mSystem) {
        var tmp, munit, amount = 18446744073709551999;
        var fromRow = this.ratios[this.unit];

        for (var m = 0; m < mSystem.length; m++) {
            tmp = this.amount * fromRow[this.ratios[mSystem[m]][0]];
            if ((tmp >= 1 && tmp < amount) || amount === 18446744073709551999) {
                amount = tmp;
                munit = mSystem[m];
            }
        }

        return {
            unit: munit,
            amount: amount
        };
    },

    /**
     * Return the normalized units identifier for the given unit. This looks up the units
     * in the aliases list and returns the normalized unit id.
     *
     * @private
     * @param {string} unit the unit to find
     * @returns {string|undefined} the normalized identifier for the given unit, or
     * undefined if there is no such unit in this type of measurement
     */
    getUnitId: function(unit) {
        if (!unit) return undefined;

        if (this.aliases && typeof(this.aliases[unit]) !== 'undefined') {
            return this.aliases[unit];
        }

        if (this.ratios && typeof(this.ratios[unit]) !== 'undefined') {
            return unit;
        }

        return undefined;
    },

    /**
     * Return the normalized units identifier for the given unit, searching case-insensitively.
     * This has the risk that things may match erroneously because many short form unit strings
     * are case-sensitive. This should method be used as a last resort if no case-sensitive match
     * is found amongst all the different types of measurements.
     *
     * @param {string} unit the unit to find
     * @returns {string|undefined} the normalized identifier for the given unit, or
     * undefined if there is no such unit in this type of measurement
     */
    getUnitIdCaseInsensitive: function(unit) {
        if (!unit) return undefined;

        // try with the original case first, just in case that works
        var ret = this.getUnitId(unit);
        if (ret) return ret;

        var u = unit.toLowerCase();
        if (this.aliasesLower && typeof(this.aliasesLower[u]) !== 'undefined') {
            return this.aliasesLower[u];
        }

        return undefined;
    }
};

/**
 * Return the normalized units identifier for the given unit. This looks up the units
 * in the aliases list and returns the normalized unit id.
 *
 * @static
 * @param {function(...)} measurement name of the the class of measure being searched
 * @param {string} unit the unit to find
 * @returns {string|undefined} the normalized identifier for the given unit, or
 * undefined if there is no such unit in this type of measurement
 */
Measurement.getUnitId = function(measurement, unit) {
    if (!unit) return undefined;

    if (typeof(measurement.aliases[unit]) !== 'undefined') {
        return measurement.aliases[unit];
    }

    if (measurement.ratios && typeof(measurement.ratios[unit]) !== 'undefined') {
        return unit;
    }

    return undefined;
};

/**
 * Return the normalized units identifier for the given unit, searching case-insensitively.
 * This has the risk that things may match erroneously because many short form unit strings
 * are case-sensitive. This should method be used as a last resort if no case-sensitive match
 * is found amongst all the different types of measurements.
 *
 * @static
 * @param {function(...)} measurement name of the class of measure being searched
 * @param {string} unit the unit to find
 * @returns {string|undefined} the normalized identifier for the given unit, or
 * undefined if there is no such unit in this type of measurement
 */
Measurement.getUnitIdCaseInsensitive = function(measurement, unit) {
    if (!unit) return undefined;
    var u = unit.toLowerCase();

    // try this first, just in case
    var ret = Measurement.getUnitId(measurement, unit);
    if (ret) return ret;

    if (measurement.aliases && !measurement.aliasesLower) {
        measurement.aliasesLower = {};
        for (var a in measurement.aliases) {
            measurement.aliasesLower[a.toLowerCase()] = measurement.aliases[a];
        }
    }

    if (typeof(measurement.aliasesLower[u]) !== 'undefined') {
        return measurement.aliasesLower[u];
    }

    return undefined;
};

// Hard-code these because CLDR has incorrect data, plus this is small so we don't
// want to do an async load just to get it.
// Source: https://en.wikipedia.org/wiki/Metrication#Overview
// Remove GB from an imperial list
// note: https://www.worldatlas.com/articles/does-england-use-the-metric-system.html
var systems = {
    "uscustomary": ["US", "FM", "MH", "LR", "PR", "PW", "GU", "WS", "AS", "VI", "MP"],
    "imperial": ["MM"]
};

// every other country in the world is metric. Myanmar (MM) is adopting metric by 2019
// supposedly, and Liberia is as well

/**
* Return the name of the measurement system in use in the given locale.
*
* @param {string|Locale} locale the locale spec or Locale instance of the
*
* @returns {string} the name of the measurement system
*/
Measurement.getMeasurementSystemForLocale = function(locale) {
  var l = typeof(locale) === "object" ? locale : new Locale(locale);
  var region = l.getRegion();

  if (region) {
      if (JSUtils.indexOf(systems.uscustomary, region) > -1) {
          return "uscustomary";
      } else if (JSUtils.indexOf(systems.imperial, region) > -1) {
          return "imperial";
      }
  }

  return "metric";
};

module.exports = Measurement;