Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to define custom units #112

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<meter>`

`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
Comment on lines +382 to +389
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why an array? Why not build the array yourself internally out of an object that's passed in? Less chance of accidentally swapping order that way.


`unitDefinition` for prefixes is sligly different.
- `aliases` is an array of aliases for the unit, e.g. `["m","meter","meters","metre","metres"]`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think "meter" is a good example for a prefix.

- `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("<CanadianDollar>", [["CAD","CanadianDollar"], 0.78, "currency", ["<dollar>"]]);
var qty = Qty('1 CAD');
qty.to('USD').toString(); // => '0.78 USD'
```

Example new base unit:
```javascript
Qty.defineUnit("<myNewUnit>", [["MYU","myNewUnit"], 1.0, "myNewKind", ["<myNewUnit>"]], true);
var qty = Qty(`1 myNewUnit`);
```

Example new prefix:
```javascript
Qty.defineUnit("<fooPrefix>", [["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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not throw an error if you find a collision?


### Errors

Every error thrown by JS-quantities is an instance of `Qty.Error`.
Expand Down
76 changes: 53 additions & 23 deletions build/quantities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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);
}
Copy link
Contributor

@rage-shadowman rage-shadowman Feb 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else throw error? silent failures are difficult to debug.

PS - What happens if oldDef was a base unit? Does this leave the new one in the expected state?

}
}
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);
}
}

/**
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))) {
Expand All @@ -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] + " ";
Expand All @@ -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] + " ";
Expand All @@ -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.
Expand All @@ -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));
}

Expand Down Expand Up @@ -1421,6 +1450,7 @@ SOFTWARE.

Qty.parse = globalParse;

Qty.defineUnit = defineUnit;
Qty.getUnits = getUnits;
Qty.getAliases = getAliases;

Expand Down
76 changes: 53 additions & 23 deletions build/quantities.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))) {
Expand All @@ -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] + " ";
Expand All @@ -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] + " ";
Expand All @@ -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.
Expand All @@ -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));
}

Expand Down Expand Up @@ -1415,6 +1444,7 @@ function toBaseUnits(numerator,denominator) {

Qty.parse = globalParse;

Qty.defineUnit = defineUnit;
Qty.getUnits = getUnits;
Qty.getAliases = getAliases;

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions spec/quantitiesSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,27 @@ describe("js-quantities", function() {
});
});

describe("Qty.defineUnit", function() {
Copy link
Contributor

@rage-shadowman rage-shadowman Feb 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit/alias collisions throwing errors, and tests for that would be nice

maybe also a way to remove an existing unit

it("should define a new unit with existing kind", function() {
Qty.defineUnit("<CanadianDollar>", [["CAD","CanadianDollar"], 0.78, "currency", ["<dollar>"]]);
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("<CanadianDollar>", [["CAD","CanadianDollar"], "invalid", "currency", ["<dollar>"]]);
}).toThrowError("<CanadianDollar>: Invalid unit definition. 'scalar' must be a number");
});
it("should define a new prefix", function() {
Qty.defineUnit("<fooPrefix>", [["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("<myNewUnit>", [["FUBAR","myNewUnit"], 1.0, "myNewKind", ["<myNewUnit>"]], 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() {
Expand Down
Loading