1 /*
  2  * Measurement.js - Measurement unit superclass
  3  *
  4  * Copyright © 2014-2015, 2018, 2021 JEDLSoft
  5  *
  6  * Licensed under the Apache License, Version 2.0 (the "License");
  7  * you may not use this file except in compliance with the License.
  8  * You may obtain a copy of the License at
  9  *
 10  *     http://www.apache.org/licenses/LICENSE-2.0
 11  *
 12  * Unless required by applicable law or agreed to in writing, software
 13  * distributed under the License is distributed on an "AS IS" BASIS,
 14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 15  *
 16  * See the License for the specific language governing permissions and
 17  * limitations under the License.
 18  */
 19 
 20 // !depends JSUtils.js MathUtils.js Locale.js
 21 
 22 var JSUtils = require("./JSUtils.js");
 23 var MathUtils = require("./MathUtils.js");
 24 var Locale = require("./Locale.js");
 25 
 26 function round(number, precision) {
 27     var factor = Math.pow(10, precision);
 28     return MathUtils.halfdown(number * factor) / factor;
 29 }
 30 
 31 /**
 32  * @class
 33  * Superclass for measurement instances that contains shared functionality
 34  * and defines the interface. <p>
 35  *
 36  * This class is never instantiated on its own. Instead, measurements should
 37  * be created using the {@link MeasurementFactory} function, which creates the
 38  * correct subclass based on the given parameters.<p>
 39  *
 40  * @param {Object=} options options controlling the construction of this instance
 41  * @private
 42  * @constructor
 43  */
 44 var Measurement = function(options) {
 45     if (options) {
 46         if (typeof(options.unit) !== 'undefined') {
 47             this.originalUnit = options.unit;
 48             this.unit = this.normalizeUnits(options.unit) || options.unit;
 49         }
 50 
 51         if (typeof(options.amount) === 'object') {
 52             if (options.amount.getMeasure() === this.getMeasure()) {
 53                 this.amount = options.amount.convert(this.unit);
 54             } else {
 55                 throw "Cannot convert unit " + options.amount.unit + " to a " + this.getMeasure();
 56             }
 57         } else if (typeof(options.amount) !== 'undefined') {
 58             this.amount = Number(options.amount);
 59         }
 60 
 61         if (typeof(this.ratios[this.unit]) === 'undefined') {
 62             throw "Unknown unit: " + options.unit;
 63         }
 64     }
 65 };
 66 
 67 /**
 68  * @private
 69  */
 70 Measurement._constructors = {};
 71 
 72 Measurement.prototype = {
 73     /**
 74      * Return the normalized name of the given units. If the units are
 75      * not recognized, this method returns its parameter unmodified.<p>
 76      *
 77      * Examples:
 78      *
 79      * <ui>
 80      * <li>"metres" gets normalized to "meter"<br>
 81      * <li>"ml" gets normalized to "milliliter"<br>
 82      * <li>"foobar" gets normalized to "foobar" (no change because it is not recognized)
 83      * </ul>
 84      *
 85      * @param {string} name name of the units to normalize.
 86      * @returns {string} normalized name of the units
 87      */
 88     normalizeUnits: function(name) {
 89         return (this.constructor && (Measurement.getUnitId(this.constructor, name) ||
 90             Measurement.getUnitIdCaseInsensitive(this.constructor, name))) ||
 91             name;
 92     },
 93 
 94     /**
 95      * Return the normalized units used in this measurement.
 96      * @return {string} name of the unit of measurement
 97      */
 98     getUnit: function() {
 99         return this.unit;
100     },
101 
102     /**
103      * Return the units originally used to construct this measurement
104      * before it was normalized.
105      * @return {string} name of the unit of measurement
106      */
107     getOriginalUnit: function() {
108         return this.originalUnit;
109     },
110 
111     /**
112      * Return the numeric amount of this measurement.
113      * @return {number} the numeric amount of this measurement
114      */
115     getAmount: function() {
116         return this.amount;
117     },
118 
119     /**
120      * Return the type of this measurement. Examples are "mass",
121      * "length", "speed", etc. Measurements can only be converted
122      * to measurements of the same type.<p>
123      *
124      * The type of the units is determined automatically from the
125      * units. For example, the unit "grams" is type "mass". Use the
126      * static call {@link Measurement.getAvailableUnits}
127      * to find out what units this version of ilib supports.
128      *
129      * @return {string} the name of the type of this measurement
130      */
131     getMeasure: function() {},
132 
133     /**
134      * Return an array of all units that this measurement types supports.
135      *
136      * @return {Array.<string>} an array of all units that this measurement
137      * types supports
138      */
139     getMeasures: function () {
140         return Object.keys(this.ratios);
141     },
142 
143     /**
144      * Return the name of the measurement system that the current
145      * unit is a part of.
146      *
147      * @returns {string} the name of the measurement system for
148      * the units of this measurement
149      */
150     getMeasurementSystem: function() {
151         if (this.unit) {
152             if (JSUtils.indexOf(this.systems.uscustomary, this.unit) > -1) {
153                 return "uscustomary";
154             }
155 
156             if (JSUtils.indexOf(this.systems.imperial, this.unit) > -1) {
157                 return "imperial";
158             }
159         }
160         return "metric";
161     },
162 
163     /**
164      * Localize the measurement to the commonly used measurement in that locale. For example
165      * If a user's locale is "en-US" and the measurement is given as "60 kmh",
166      * the formatted number should be automatically converted to the most appropriate
167      * measure in the other system, in this case, mph. The formatted result should
168      * appear as "37.3 mph".
169      *
170      * @param {string} locale current locale string
171      * @returns {Measurement} a new instance that is converted to locale
172      */
173     localize: function(locale) {
174         var to;
175         var toSystem = Measurement.getMeasurementSystemForLocale(locale);
176         var fromSystem = this.getMeasurementSystem();
177         if (toSystem === fromSystem) return this; // already there
178         to = this.systems.conversions[fromSystem] &&
179             this.systems.conversions[fromSystem][toSystem] &&
180             this.systems.conversions[fromSystem][toSystem][this.unit];
181 
182         return to ? this.newUnit({
183             unit: to,
184             amount: this.convert(to)
185         }) : this;
186     },
187 
188     /**
189      * Return the amount of the current measurement when converted to the
190      * given measurement unit. Measurements can only be converted
191      * to other measurements of the same type.<p>
192      *
193      * @param {string} to the name of the units to convert this measurement to
194      * @return {number|undefined} the amount corresponding to the requested unit
195      */
196     convert: function(to) {
197         if (!to || typeof(this.ratios[this.normalizeUnits(to)]) === 'undefined') {
198             return undefined;
199         }
200 
201         var from = this.getUnitIdCaseInsensitive(this.unit) || this.unit;
202         to = this.getUnitIdCaseInsensitive(to) || to;
203         if (typeof(from) === 'undefined' || typeof(to) === 'undefined') {
204             return undefined;
205         }
206 
207         var fromRow = this.ratios[from];
208         var toRow = this.ratios[to];
209         return this.amount * fromRow[toRow[0]];
210     },
211 
212     /**
213      * Return a new measurement instance that is converted to a different
214      * measurement system. Measurements can only be converted
215      * to other measurements of the same type.<p>
216      *
217      * @param {string} measurementSystem the name of the system to convert to
218      * @return {Measurement} a new measurement in the given system, or the
219      * current measurement if it is already in the given system or could not
220      * be converted
221      */
222     convertSystem: function(measurementSystem) {
223         if (!measurementSystem || measurementSystem === this.getMeasurementSystem()) {
224             return this;
225         }
226         var map = this.systems.conversions[this.getMeasurementSystem()][measurementSystem];
227         var newunit = map && map[this.unit];
228         if (!newunit) return this;
229 
230         return this.newUnit({
231             unit: newunit,
232             amount: this.convert(newunit)
233         });
234     },
235 
236     /**
237      * Scale the measurement unit to an acceptable level. The scaling
238      * happens so that the integer part of the amount is as small as
239      * possible without being below zero. This will result in the
240      * largest units that can represent this measurement without
241      * fractions. Measurements can only be scaled to other measurements
242      * of the same type.
243      *
244      * @param {string=} measurementsystem the name of the system to scale to
245      * @param {Object=} units mapping from the measurement system to the units to use
246      * for this scaling. If this is not defined, this measurement type will use the
247      * set of units that it knows about for the given measurement system
248      * @return {Measurement} a new instance that is scaled to the
249      * right level
250      */
251     scale: function(measurementsystem, units) {
252         var systemName = this.getMeasurementSystem();
253         var mSystem;
254         if (units) {
255             mSystem = (units[measurementsystem] && JSUtils.indexOf(units[measurementsystem], this.unit) > -1) ?
256                 units[measurementsystem] : units[systemName];
257         }
258         if (!mSystem) {
259             mSystem = (this.systems[measurementsystem] && JSUtils.indexOf(this.systems[measurementsystem], this.unit) > -1) ?
260                 this.systems[measurementsystem] : this.systems[systemName];
261         }
262         if (!mSystem) {
263             // cannot find the system to scale within... just return the measurement as is
264             return this;
265         }
266 
267         return this.newUnit(this.scaleUnits(mSystem));
268     },
269 
270     /**
271      * Expand the current measurement such that any fractions of the current unit
272      * are represented in terms of smaller units in the same system instead of fractions
273      * of the current unit. For example, "6.25 feet" may be represented as
274      * "6 feet 4 inches" instead. The return value is an array of measurements which
275      * are progressively smaller until the smallest unit in the system is reached
276      * or until there is a whole number of any unit along the way.
277      *
278      * @param {string=} measurementsystem system to use (uscustomary|imperial|metric),
279      * or undefined if the system can be inferred from the current measure
280      * @param {Array.<string>=} units object containing a mapping between the measurement system
281      * and an array of units to use to restrict the expansion to
282      * @param {function(number):number=} constrain a function that constrains
283      * a number according to the display options
284      * @param {boolean=} scale if true, rescale all of the units so that the
285      * largest unit is the largest one with a non-fractional number. If false, then
286      * the current unit stays the largest unit.
287      * @return {Array.<Measurement>} an array of new measurements in order from
288      * the current units to the smallest units in the system which together are the
289      * same measurement as this one
290      */
291     expand: function(measurementsystem, units, constrain, scale) {
292         var systemName = this.getMeasurementSystem();
293         var mSystem = (units && units[systemName]) ? units[systemName] : (this.systems[systemName] || this.systems.metric);
294 
295         return this.list(mSystem, this.ratios, constrain, scale).map(function(item) {
296             return this.newUnit(item);
297         }.bind(this));
298     },
299 
300     /**
301      * Convert the current measurement to a list of measures
302      * and amounts. This method will autoScale the current measurement
303      * to the largest measure in the given measures list such that the
304      * amount of that measure is still greater than or equal to 1. From
305      * there, it will truncate that measure to a whole
306      * number and then it will calculate the remainder in terms of
307      * each of the smaller measures in the given list.<p>
308      *
309      * For example, if a person's height is given as 70.5 inches, and
310      * the list of measures is ["mile", "foot", "inch"], then it will
311      * scale the amount to 5 feet, 10.5 inches. The amount is not big
312      * enough to have any whole miles, so that measure is not used.
313      * The first measure will be "foot" because it is the first one
314      * in the measure list where the there is an amount of them that
315      * is greater than or equal to 1. The return value in this example
316      * would be:
317      *
318      * <pre>
319      * [
320      *   {
321      *     "unit": "foot",
322      *     "amount": 5
323      *   },
324      *   {
325      *     "unit": "inch",
326      *     "amount": 10.5
327      *   }
328      * ]
329      * </pre>
330      *
331      * Note that all measures except the smallest will be returned
332      * as whole numbers. The smallest measure will contain any possible
333      * fractional remainder.
334      *
335      * @param {Array.<string>|undefined} measures array of measure names to
336      * convert this measure to
337      * @param {Object} ratios the conversion ratios
338      * table for the measurement type
339      * @param {function (number): number=} constrain a function that constrains
340      * a number according to the display options
341      * @param {boolean=} scale if true, rescale all of the units so that the
342      * largest unit is the largest one with a non-fractional number. If false, then
343      * the current unit stays the largest unit.
344      * @returns {Array.<{unit: String, amount: Number}>} the conversion
345      * of the current measurement into an array of unit names and
346      * their amounts
347      */
348     list: function(measures, ratios, constrain, scale) {
349         var row = ratios[this.unit];
350         var ret = [];
351         var scaled;
352         var unit = this.unit;
353         var amount = this.amount;
354         constrain = constrain || round;
355 
356         var start = JSUtils.indexOf(measures, this.unit);
357 
358         if (scale || start === -1) {
359             start = measures.length-1;
360         }
361 
362         if (this.unit !== measures[0]) {
363             // if this unit is not the smallest measure in the system, we have to convert
364             unit = measures[0];
365             amount = this.amount * row[ratios[unit][0]];
366             row = ratios[unit];
367         }
368 
369         // convert to smallest measure
370         amount = constrain(amount);
371         // go backwards so we get from the largest to the smallest units in order
372         for (var j = start; j > 0; j--) {
373             unit = measures[j];
374             scaled = amount * row[ratios[unit][0]];
375             var xf = Math.floor(scaled);
376             if (xf) {
377                 var item = {
378                     unit: unit,
379                     amount: xf
380                 };
381                 ret.push(item);
382 
383                 amount -= xf * ratios[unit][ratios[measures[0]][0]];
384             }
385         }
386 
387         // last measure is rounded/constrained, not truncated
388         if (amount !== 0) {
389             ret.push({
390                 unit: measures[0],
391                 amount: constrain(amount)
392             });
393         }
394 
395         return ret;
396     },
397 
398     /**
399      * @private
400      */
401     scaleUnits: function(mSystem) {
402         var tmp, munit, amount = 18446744073709551999;
403         var fromRow = this.ratios[this.unit];
404 
405         for (var m = 0; m < mSystem.length; m++) {
406             tmp = this.amount * fromRow[this.ratios[mSystem[m]][0]];
407             if ((tmp >= 1 && tmp < amount) || amount === 18446744073709551999) {
408                 amount = tmp;
409                 munit = mSystem[m];
410             }
411         }
412 
413         return {
414             unit: munit,
415             amount: amount
416         };
417     },
418 
419     /**
420      * @private
421      *
422      * Return the normalized units identifier for the given unit. This looks up the units
423      * in the aliases list and returns the normalized unit id.
424      *
425      * @param {string} unit the unit to find
426      * @returns {string|undefined} the normalized identifier for the given unit, or
427      * undefined if there is no such unit in this type of measurement
428      */
429     getUnitId: function(unit) {
430         if (!unit) return undefined;
431 
432         if (this.aliases && typeof(this.aliases[unit]) !== 'undefined') {
433             return this.aliases[unit];
434         }
435 
436         if (this.ratios && typeof(this.ratios[unit]) !== 'undefined') {
437             return unit;
438         }
439 
440         return undefined;
441     },
442 
443     /**
444      * Return the normalized units identifier for the given unit, searching case-insensitively.
445      * This has the risk that things may match erroneously because many short form unit strings
446      * are case-sensitive. This should method be used as a last resort if no case-sensitive match
447      * is found amongst all the different types of measurements.
448      *
449      * @param {string} unit the unit to find
450      * @returns {string|undefined} the normalized identifier for the given unit, or
451      * undefined if there is no such unit in this type of measurement
452      */
453     getUnitIdCaseInsensitive: function(unit) {
454         if (!unit) return undefined;
455 
456         // try with the original case first, just in case that works
457         var ret = this.getUnitId(unit);
458         if (ret) return ret;
459 
460         var u = unit.toLowerCase();
461         if (this.aliasesLower && typeof(this.aliasesLower[u]) !== 'undefined') {
462             return this.aliasesLower[u];
463         }
464 
465         return undefined;
466     }
467 };
468 
469 /**
470  * Return the normalized units identifier for the given unit. This looks up the units
471  * in the aliases list and returns the normalized unit id.
472  *
473  * @static
474  * @param {function(...)} measurement name of the the class of measure being searched
475  * @param {string} unit the unit to find
476  * @returns {string|undefined} the normalized identifier for the given unit, or
477  * undefined if there is no such unit in this type of measurement
478  */
479 Measurement.getUnitId = function(measurement, unit) {
480     if (!unit) return undefined;
481 
482     if (typeof(measurement.aliases[unit]) !== 'undefined') {
483         return measurement.aliases[unit];
484     }
485 
486     if (measurement.ratios && typeof(measurement.ratios[unit]) !== 'undefined') {
487         return unit;
488     }
489 
490     return undefined;
491 };
492 
493 /**
494  * Return the normalized units identifier for the given unit, searching case-insensitively.
495  * This has the risk that things may match erroneously because many short form unit strings
496  * are case-sensitive. This should method be used as a last resort if no case-sensitive match
497  * is found amongst all the different types of measurements.
498  *
499  * @static
500  * @param {function(...)} measurement name of the class of measure being searched
501  * @param {string} unit the unit to find
502  * @returns {string|undefined} the normalized identifier for the given unit, or
503  * undefined if there is no such unit in this type of measurement
504  */
505 Measurement.getUnitIdCaseInsensitive = function(measurement, unit) {
506     if (!unit) return undefined;
507     var u = unit.toLowerCase();
508 
509     // try this first, just in case
510     var ret = Measurement.getUnitId(measurement, unit);
511     if (ret) return ret;
512 
513     if (measurement.aliases && !measurement.aliasesLower) {
514         measurement.aliasesLower = {};
515         for (var a in measurement.aliases) {
516             measurement.aliasesLower[a.toLowerCase()] = measurement.aliases[a];
517         }
518     }
519 
520     if (typeof(measurement.aliasesLower[u]) !== 'undefined') {
521         return measurement.aliasesLower[u];
522     }
523 
524     return undefined;
525 };
526 
527 // Hard-code these because CLDR has incorrect data, plus this is small so we don't
528 // want to do an async load just to get it.
529 // Source: https://en.wikipedia.org/wiki/Metrication#Overview
530 // Remove GB from an imperial list
531 // note: https://www.worldatlas.com/articles/does-england-use-the-metric-system.html
532 var systems = {
533     "uscustomary": ["US", "FM", "MH", "LR", "PR", "PW", "GU", "WS", "AS", "VI", "MP"],
534     "imperial": ["MM"]
535 };
536 
537 // every other country in the world is metric. Myanmar (MM) is adopting metric by 2019
538 // supposedly, and Liberia is as well
539 
540 /**
541 * Return the name of the measurement system in use in the given locale.
542 *
543 * @param {string|Locale} locale the locale spec or Locale instance of the
544 *
545 * @returns {string} the name of the measurement system
546 */
547 Measurement.getMeasurementSystemForLocale = function(locale) {
548   var l = typeof(locale) === "object" ? locale : new Locale(locale);
549   var region = l.getRegion();
550 
551   if (region) {
552       if (JSUtils.indexOf(systems.uscustomary, region) > -1) {
553           return "uscustomary";
554       } else if (JSUtils.indexOf(systems.imperial, region) > -1) {
555           return "imperial";
556       }
557   }
558 
559   return "metric";
560 };
561 
562 module.exports = Measurement;
563