From e5e261ccd711cc4a25c41d6c3a2dc0b77c3caf18 Mon Sep 17 00:00:00 2001 From: Phil Mitchell Date: Mon, 7 Dec 2020 13:55:31 -0500 Subject: [PATCH] Add support to define custom units --- README.md | 53 ++++++++++++++++++++++++ build/quantities.js | 76 ++++++++++++++++++++++++----------- build/quantities.mjs | 76 ++++++++++++++++++++++++----------- package-lock.json | 2 +- spec/quantitiesSpec.js | 21 ++++++++++ src/quantities/definitions.js | 24 +++++++++-- src/quantities/global-api.js | 4 +- src/quantities/parse.js | 51 ++++++++++++++--------- 8 files changed, 236 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 2db1ea5..081230e 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,59 @@ Conversions are probably better done like this... Qty('0 tempC').add('100 degC') // => 100 tempC ``` +### Custom units and prefixes + +You can define your own custom units and prefixes by using the `Qty.defineUnit` +function. + +```javascript +Qty.defineUnit( unitName, unitDefinition[, isBase] ); +``` + +`unitName` is the official name of the unit wrapped in `<` and `>`, +e.g. `` + +`unitDefinition` is an array of definition values. For units it is: + - `aliases` is an array of aliases for the unit, e.g. `["m","meter","meters","metre","metres"]` + - `conversion` is a decimal number that can be multiplied to the number to + convert to the base unit + - `kind` is the kind of the unit. If you specify an unknown kind here, be + sure to include a unit with `isBase` set to true for doing conversions + - `numerator` is an array containing the set of units that can be specified as a numerator for this unit + - `denominator` is an array containing the set of units that can be specified as a denominator for this unit + +`unitDefinition` for prefixes is sligly different. + - `aliases` is an array of aliases for the unit, e.g. `["m","meter","meters","metre","metres"]` + - `conversion` is a decimal number that can be multiplied to the number to + convert to the base non-prefixed unit + - `kind` must be `prefix` + +`isBase` indicates whether this is a base unit for the specified kind. You +must include one base unit for each kind. + +Example new unit: +```javascript +Qty.defineUnit("", [["CAD","CanadianDollar"], 0.78, "currency", [""]]); +var qty = Qty('1 CAD'); +qty.to('USD').toString(); // => '0.78 USD' +``` + +Example new base unit: +```javascript +Qty.defineUnit("", [["MYU","myNewUnit"], 1.0, "myNewKind", [""]], true); +var qty = Qty(`1 myNewUnit`); +``` + +Example new prefix: +```javascript +Qty.defineUnit("", [["foo"], 1e5, "prefix"]); +var qty = Qty(`3 foometers`); +qty.to('meters').toString(); // => '300000 meters' +``` + +Defining new units should be done carefully and tested thoroughly as it can +introduce conflicts while parsing other units. + ### Errors Every error thrown by JS-quantities is an instance of `Qty.Error`. diff --git a/build/quantities.js b/build/quantities.js index 464a3d7..97944b7 100644 --- a/build/quantities.js +++ b/build/quantities.js @@ -510,9 +510,11 @@ SOFTWARE. var UNIT_VALUES = {}; var UNIT_MAP = {}; var OUTPUT_MAP = {}; - for (var unitDef in UNITS) { - if (UNITS.hasOwnProperty(unitDef)) { - var definition = UNITS[unitDef]; + + function defineUnit(unitDef, definition, isBase) { + let oldDef = UNITS[unitDef]; + try { + UNITS[unitDef] = definition; if (definition[2] === "prefix") { PREFIX_VALUES[unitDef] = definition[1]; for (var i = 0; i < definition[0].length; i++) { @@ -529,9 +531,25 @@ SOFTWARE. for (var j = 0; j < definition[0].length; j++) { UNIT_MAP[definition[0][j]] = unitDef; } + if (isBase) { + if (BASE_UNITS.indexOf(unitDef) === -1) { + BASE_UNITS.push(unitDef); + } + } } OUTPUT_MAP[unitDef] = definition[0][0]; } + catch (e) { + UNITS[unitDef] = oldDef; + throw e; + } + } + + for (var unitDef in UNITS) { + if (UNITS.hasOwnProperty(unitDef)) { + var definition = UNITS[unitDef]; + defineUnit(unitDef, definition); + } } /** @@ -667,6 +685,30 @@ SOFTWARE. var TOP_REGEX = new RegExp ("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(-?" + SAFE_POWER + "(?![a-zA-Z]))"); var BOTTOM_REGEX = new RegExp("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(" + SAFE_POWER + "(?![a-zA-Z]))"); + function getRegexes() { + var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + + /* + * Minimal boundary regex to support units with Unicode characters + * \b only works for ASCII + */ + var BOUNDARY_REGEX = "\\b|$"; + var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + + UNIT_REGEX + + ")(?:" + BOUNDARY_REGEX + ")"; + var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); + var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences + + return { + UNIT_TEST_REGEX, + UNIT_MATCH_REGEX + }; + } /* parse a string into a unit object. * Typical formats like : * "5.6 kg*m/s^2" @@ -702,6 +744,8 @@ SOFTWARE. var top = result[2]; var bottom = result[3]; + var regexes = getRegexes(); + var n, x, nx; // TODO DRY me while ((result = TOP_REGEX.exec(top))) { @@ -711,7 +755,7 @@ SOFTWARE. throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -735,7 +779,7 @@ SOFTWARE. throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -755,22 +799,6 @@ SOFTWARE. } } - var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { - return b.length - a.length; - }).join("|"); - var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { - return b.length - a.length; - }).join("|"); - /* - * Minimal boundary regex to support units with Unicode characters - * \b only works for ASCII - */ - var BOUNDARY_REGEX = "\\b|$"; - var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + - UNIT_REGEX + - ")(?:" + BOUNDARY_REGEX + ")"; - var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); - var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences var parsedUnitsCache = {}; /** * Parses and converts units string to normalized unit array. @@ -792,12 +820,13 @@ SOFTWARE. var unitMatch, normalizedUnits = []; + var regexes = getRegexes(); // Scan - if (!UNIT_TEST_REGEX.test(units)) { + if (!regexes.UNIT_TEST_REGEX.test(units)) { throw new QtyError("Unit not recognized"); } - while ((unitMatch = UNIT_MATCH_REGEX.exec(units))) { + while ((unitMatch = regexes.UNIT_MATCH_REGEX.exec(units))) { normalizedUnits.push(unitMatch.slice(1)); } @@ -1421,6 +1450,7 @@ SOFTWARE. Qty.parse = globalParse; + Qty.defineUnit = defineUnit; Qty.getUnits = getUnits; Qty.getAliases = getAliases; diff --git a/build/quantities.mjs b/build/quantities.mjs index 1a25dde..b34b037 100644 --- a/build/quantities.mjs +++ b/build/quantities.mjs @@ -504,9 +504,11 @@ var PREFIX_MAP = {}; var UNIT_VALUES = {}; var UNIT_MAP = {}; var OUTPUT_MAP = {}; -for (var unitDef in UNITS) { - if (UNITS.hasOwnProperty(unitDef)) { - var definition = UNITS[unitDef]; + +function defineUnit(unitDef, definition, isBase) { + let oldDef = UNITS[unitDef]; + try { + UNITS[unitDef] = definition; if (definition[2] === "prefix") { PREFIX_VALUES[unitDef] = definition[1]; for (var i = 0; i < definition[0].length; i++) { @@ -523,9 +525,25 @@ for (var unitDef in UNITS) { for (var j = 0; j < definition[0].length; j++) { UNIT_MAP[definition[0][j]] = unitDef; } + if (isBase) { + if (BASE_UNITS.indexOf(unitDef) === -1) { + BASE_UNITS.push(unitDef); + } + } } OUTPUT_MAP[unitDef] = definition[0][0]; } + catch (e) { + UNITS[unitDef] = oldDef; + throw e; + } +} + +for (var unitDef in UNITS) { + if (UNITS.hasOwnProperty(unitDef)) { + var definition = UNITS[unitDef]; + defineUnit(unitDef, definition); + } } /** @@ -661,6 +679,30 @@ var SAFE_POWER = "[01234]"; var TOP_REGEX = new RegExp ("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(-?" + SAFE_POWER + "(?![a-zA-Z]))"); var BOTTOM_REGEX = new RegExp("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(" + SAFE_POWER + "(?![a-zA-Z]))"); +function getRegexes() { + var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + + /* + * Minimal boundary regex to support units with Unicode characters + * \b only works for ASCII + */ + var BOUNDARY_REGEX = "\\b|$"; + var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + + UNIT_REGEX + + ")(?:" + BOUNDARY_REGEX + ")"; + var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); + var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences + + return { + UNIT_TEST_REGEX, + UNIT_MATCH_REGEX + }; +} /* parse a string into a unit object. * Typical formats like : * "5.6 kg*m/s^2" @@ -696,6 +738,8 @@ function parse(val) { var top = result[2]; var bottom = result[3]; + var regexes = getRegexes(); + var n, x, nx; // TODO DRY me while ((result = TOP_REGEX.exec(top))) { @@ -705,7 +749,7 @@ function parse(val) { throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -729,7 +773,7 @@ function parse(val) { throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -749,22 +793,6 @@ function parse(val) { } } -var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { - return b.length - a.length; -}).join("|"); -var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { - return b.length - a.length; -}).join("|"); -/* - * Minimal boundary regex to support units with Unicode characters - * \b only works for ASCII - */ -var BOUNDARY_REGEX = "\\b|$"; -var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + - UNIT_REGEX + - ")(?:" + BOUNDARY_REGEX + ")"; -var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); -var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences var parsedUnitsCache = {}; /** * Parses and converts units string to normalized unit array. @@ -786,12 +814,13 @@ function parseUnits(units) { var unitMatch, normalizedUnits = []; + var regexes = getRegexes(); // Scan - if (!UNIT_TEST_REGEX.test(units)) { + if (!regexes.UNIT_TEST_REGEX.test(units)) { throw new QtyError("Unit not recognized"); } - while ((unitMatch = UNIT_MATCH_REGEX.exec(units))) { + while ((unitMatch = regexes.UNIT_MATCH_REGEX.exec(units))) { normalizedUnits.push(unitMatch.slice(1)); } @@ -1415,6 +1444,7 @@ function toBaseUnits(numerator,denominator) { Qty.parse = globalParse; +Qty.defineUnit = defineUnit; Qty.getUnits = getUnits; Qty.getAliases = getAliases; diff --git a/package-lock.json b/package-lock.json index d4a99b1..296d31f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "js-quantities", - "version": "1.7.5", + "version": "1.7.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/spec/quantitiesSpec.js b/spec/quantitiesSpec.js index 0ac94f8..f499773 100644 --- a/spec/quantitiesSpec.js +++ b/spec/quantitiesSpec.js @@ -1476,6 +1476,27 @@ describe("js-quantities", function() { }); }); + describe("Qty.defineUnit", function() { + it("should define a new unit with existing kind", function() { + Qty.defineUnit("", [["CAD","CanadianDollar"], 0.78, "currency", [""]]); + expect(Qty.getUnits("currency")).toContain("CanadianDollar"); + expect(Qty("1 CAD").eq(Qty("0.78 USD"))); + }); + it("should validate the unit conversion factor", function() { + expect(function() { + Qty.defineUnit("", [["CAD","CanadianDollar"], "invalid", "currency", [""]]); + }).toThrowError(": Invalid unit definition. 'scalar' must be a number"); + }); + it("should define a new prefix", function() { + Qty.defineUnit("", [["foo"], 1e5, "prefix"]); + expect(Qty("3 foometers").eq(Qty("300000 m"))).toBe(true); + }); + it("should define a new unit with new kind", function() { + Qty.defineUnit("", [["FUBAR","myNewUnit"], 1.0, "myNewKind", [""]], true); + expect(Qty("300 FUBAR").eq(Qty("3 hectomyNewUnit"))).toBe(true); + }); + }); + describe("information", function() { describe("bits and bytes", function() { it("should have 'information' as kind", function() { diff --git a/src/quantities/definitions.js b/src/quantities/definitions.js index 153b3cb..2fba1f2 100644 --- a/src/quantities/definitions.js +++ b/src/quantities/definitions.js @@ -316,9 +316,11 @@ export var PREFIX_MAP = {}; export var UNIT_VALUES = {}; export var UNIT_MAP = {}; export var OUTPUT_MAP = {}; -for (var unitDef in UNITS) { - if (UNITS.hasOwnProperty(unitDef)) { - var definition = UNITS[unitDef]; + +export function defineUnit(unitDef, definition, isBase) { + let oldDef = UNITS[unitDef]; + try { + UNITS[unitDef] = definition; if (definition[2] === "prefix") { PREFIX_VALUES[unitDef] = definition[1]; for (var i = 0; i < definition[0].length; i++) { @@ -335,9 +337,25 @@ for (var unitDef in UNITS) { for (var j = 0; j < definition[0].length; j++) { UNIT_MAP[definition[0][j]] = unitDef; } + if (isBase) { + if (BASE_UNITS.indexOf(unitDef) === -1) { + BASE_UNITS.push(unitDef); + } + } } OUTPUT_MAP[unitDef] = definition[0][0]; } + catch (e) { + UNITS[unitDef] = oldDef; + throw e; + } +} + +for (var unitDef in UNITS) { + if (UNITS.hasOwnProperty(unitDef)) { + var definition = UNITS[unitDef]; + defineUnit(unitDef, definition); + } } /** diff --git a/src/quantities/global-api.js b/src/quantities/global-api.js index ff9d3dc..63d57ab 100644 --- a/src/quantities/global-api.js +++ b/src/quantities/global-api.js @@ -5,7 +5,8 @@ import { } from "./utils.js"; import { getAliases, - getUnits + getUnits, + defineUnit } from "./definitions.js"; import { getKinds } from "./kind.js"; import { swiftConverter } from "./conversion.js"; @@ -15,6 +16,7 @@ import Qty from "./constructor.js"; Qty.parse = globalParse; +Qty.defineUnit = defineUnit; Qty.getUnits = getUnits; Qty.getAliases = getAliases; diff --git a/src/quantities/parse.js b/src/quantities/parse.js index 346c220..07a5dbb 100644 --- a/src/quantities/parse.js +++ b/src/quantities/parse.js @@ -22,6 +22,30 @@ var SAFE_POWER = "[01234]"; var TOP_REGEX = new RegExp ("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(-?" + SAFE_POWER + "(?![a-zA-Z]))"); var BOTTOM_REGEX = new RegExp("([^ \\*\\d]+?)(?:" + POWER_OP + ")?(" + SAFE_POWER + "(?![a-zA-Z]))"); +function getRegexes() { + var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { + return b.length - a.length; + }).join("|"); + + /* + * Minimal boundary regex to support units with Unicode characters + * \b only works for ASCII + */ + var BOUNDARY_REGEX = "\\b|$"; + var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + + UNIT_REGEX + + ")(?:" + BOUNDARY_REGEX + ")"; + var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); + var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences + + return { + UNIT_TEST_REGEX, + UNIT_MATCH_REGEX + }; +} /* parse a string into a unit object. * Typical formats like : * "5.6 kg*m/s^2" @@ -57,6 +81,8 @@ export default function parse(val) { var top = result[2]; var bottom = result[3]; + var regexes = getRegexes(); + var n, x, nx; // TODO DRY me while ((result = TOP_REGEX.exec(top))) { @@ -66,7 +92,7 @@ export default function parse(val) { throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -90,7 +116,7 @@ export default function parse(val) { throw new QtyError("Unit exponent is not a number"); } // Disallow unrecognized unit even if exponent is 0 - if (n === 0 && !UNIT_TEST_REGEX.test(result[1])) { + if (n === 0 && !regexes.UNIT_TEST_REGEX.test(result[1])) { throw new QtyError("Unit not recognized"); } x = result[1] + " "; @@ -110,22 +136,6 @@ export default function parse(val) { } } -var PREFIX_REGEX = Object.keys(PREFIX_MAP).sort(function(a, b) { - return b.length - a.length; -}).join("|"); -var UNIT_REGEX = Object.keys(UNIT_MAP).sort(function(a, b) { - return b.length - a.length; -}).join("|"); -/* - * Minimal boundary regex to support units with Unicode characters - * \b only works for ASCII - */ -var BOUNDARY_REGEX = "\\b|$"; -var UNIT_MATCH = "(" + PREFIX_REGEX + ")??(" + - UNIT_REGEX + - ")(?:" + BOUNDARY_REGEX + ")"; -var UNIT_TEST_REGEX = new RegExp("^\\s*(" + UNIT_MATCH + "[\\s\\*]*)+$"); -var UNIT_MATCH_REGEX = new RegExp(UNIT_MATCH, "g"); // g flag for multiple occurences var parsedUnitsCache = {}; /** * Parses and converts units string to normalized unit array. @@ -147,12 +157,13 @@ function parseUnits(units) { var unitMatch, normalizedUnits = []; + var regexes = getRegexes(); // Scan - if (!UNIT_TEST_REGEX.test(units)) { + if (!regexes.UNIT_TEST_REGEX.test(units)) { throw new QtyError("Unit not recognized"); } - while ((unitMatch = UNIT_MATCH_REGEX.exec(units))) { + while ((unitMatch = regexes.UNIT_MATCH_REGEX.exec(units))) { normalizedUnits.push(unitMatch.slice(1)); }