Displaying metric and imperial measurements
Websites that deal with calculations and measurements often need to solve the problem of using both metric and imperial units of measurement. This recipe will demonstrate a data-driven approach to dealing with unit conversions. As this is an HTML5 book, the solution will be implemented on the client side rather than on the server side.
We're going to implement a client-side, "ideal weight" calculator that supports metric and imperial measurements. This time, we're going to create a more general and elegant data-driven solution that utilizes modern HTML5 capabilities, such as data attributes. The goal is to abstract away the messy and error-prone conversion as much as possible.
Getting ready
The formula for calculating the body mass index (BMI) is as follows:
BMI = (Weight in kilograms / (height in meters x height in meters))
We're going to use BMI = 22 to calculate the "ideal weight".
How to do it...
Create the following HTML page:
<!DOCTYPE HTML> <html> <head> <title>BMI Units</title> </head> <body> <label>Unit system</label> <select id="unit"> <option selected value="height=m,cm 0;weight=kg 1;distance=km 1">Metric</option> <option value="height=ft,inch 0;weight=lbs 0;distance=mi 1">Imperial</option> </select><br> <label>Height</label> <span data-measurement="height" id="height"> <input data-value-display type="text" id="height" class="calc"> <span data-unit-display></span> <input data-value-display type="text" id="height" class="calc"> <span data-unit-display></span> </span> <br> <label>Ideal Weight</label> <span data-measurement="weight" id="weight"> <span data-value-display type="text">0</span> <span data-unit-display></span> </span> <br> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script> <script type="text/javascript" src="unitval.js"></script> <script type="text/javascript" src="example.js"></script> </script> </body> </html>
This page looks very much like the regular page we would make for a BMI-based ideal weight calculator. The main differences are as follows:
We have an imperial/metric selection input
We also have additional custom data attributes to give special meanings to the HTML fields
We use
data-measurement
to denote the kind of measurement that the element will display (for example, either the weight or the height)We use
data-display-unit
anddata-display-value
to denote fields that display unit strings and values of the measurement respectively
Create a file named
example.js
with the following code:(function() { // Setup unitval $.unitval({ weight: { "lbs": 0.453592, // kg "kg" : 1 // kg }, height: { "ft" : 0.3048, // m "inch": 0.0254, // m "m" : 1, // m "cm" : 0.01, // m } }); $("#unit").change(function() { var measurementUnits = $(this).val().split(';').map(function(u) { var type_unitround = u.split('='), unitround = type_unitround[1].split(' '); return { type: type_unitround[0], units: unitround[0].split(','), round: unitround[1] }; }); // Setup units for measurements. $('body').unitval(measurementUnits); }); $("#unit").trigger("change"); $('#height').on('keyup change',function() { var height = $('#height').unitval(), bmi = 22; var idealWeight = bmi * height * height; $("#weight").unitval(idealWeight); }); }
The first part of the code configures a jQuery plugin called
unitval
, with the conversion factors for the measurements and units that we are going to use (weight and height).The second part sets the measurement units for the document by reading the specification from the
select
field. It specifies an array of measurements, each having the following:A type string, for example
"height"
A list of units, for example
["ft", "inch"]
The number of decimals to use for the last unit
The third part is a regular calculator that is written almost exactly as it would be written if there were no unit conversions. The only exception is that values are taken from the elements that have the
data-measurement
attribute using the jQuery plugin named$.unitval
.We're going to write a generic unit converter. It will need two functions: one that will convert user-displayed (input) data to standard international (SI) measurement units, and another to convert it back from SI units to user-friendly display units. Our converter will support using multiple units at the same time. When converting from input, the first argument is the measurement type (for example, distance), the second is an array of value-unit pairs (for example,
[[5, 'km'], [300,'m']]
), a single pair (for example[5,'km']
), or simply the value (for example5
).If the second parameter is a simple value, we're going to accept a third one containing the unit (for example
'km'
). The output is always a simple SI value.When converting a value to the desired output units, we specify the units as an array, for example, as either
['km', 'm']
or as a single unit. We also specify rounding decimals for the last unit. Our output is an array of converted values.Conversion is done using the values in the
Factors
object. This object contains a property for every measurement name that we're going to use. Each such property is an object with the available units for that measurement as properties, and their SI factors as values. Look in the following inexample.js
for an example.The source code of the jQuery plugin,
unitval.js
, is as follows:(function() { var Factors = {}; var Convert = window.Convert = { fromInput: function(measurement, valunits, unit) { valunits = unit ? [[valunits, unit]] // 3 arguments : valunits instanceof Array && valunits[0] instanceof Array ? valunits : [valunits]; // [val, unit] array var sivalues = valunits.map(function(valunit) { // convert each to SI return valunit[0] * Factors[measurement][valunit[1]]; }); // sivalues.sum(): return sivalues.reduce(function(a, e) { return a + e; }); }, toOutput: function(measurement, val, units, round) { units = units instanceof Array ? units : [units]; var reduced = val; return units.map(function(unit, index) { var isLast = index == units.length - 1, factor = Factors[measurement][unit]; var showValue = reduced / factor; if (isLast && (typeof(round) != 'undefined')) showValue = showValue.toFixed(round) - 0; else if (!isLast) showValue = Math.floor(showValue); reduced -= showValue * factor; return showValue; }); } }; $.unitval = function(fac) { Factors = fac; } // Uses .val() in input/textarea and .text() in other fields. var uval = function() { return ['input','textarea'].indexOf(this[0].tagName.toLowerCase()) < 0 ? this.text.apply(this, arguments) : this.val.apply(this, arguments); }
Our generic convertor is useful, but not very convenient or user friendly; we still have to do all the conversions manually. To avoid this, we're going to put data attributes on our elements, denoting the measurements that they display. Inside them, we're going to put separate elements for displaying the value(s) and unit(s). When we set the measurement units, the function
setMeasurementUnits
will set them on every element that has this data attribute. Furthermore, it will also adjust the inner value and unit elements accordingly:// Sets the measurement units within a specific element. // @param measurements An array in the format [{type:"measurement", units: ["unit", ...], round:N}] // for example [{type:"height", units:["ft","inch"], round:0}] var setMeasurementUnits = function(measurements) { var $this = this; measurements.forEach(function(measurement) { var holders = $this.find('[data-measurement="'+measurement.type+'"]'); var unconverted = holders.map(function() { return $(this).unitval(); }) holders.attr('data-round', measurement.round); holders.find('[data-value-display]').each(function(index) { if (index < measurement.units.length) $(this).show().attr('data-unit', measurement.units[index]); else $(this).hide(); }); holders.find('[data-unit-display]').each(function(index) { if (index < measurement.units.length) $(this).show().html(measurement.units[index]); else $(this).hide(); }); holders.each(function(index) { $(this).unitval(unconverted[index]); }); }); };
As every element knows its measurement and units, we can now simply put SI values inside them and have them display converted values. To do this, we'll write
unitval
. It allows us to set and get "united" values, or set unit options on elements that have thedata-measurement
property:$.fn.unitval = function(value) { if (value instanceof Array) { setMeasurementUnits.apply(this, arguments); } else if (typeof(value) == 'undefined') { // Read value from element var first = this.eq(0), measurement = first.attr('data-measurement'), displays = first.find('[data-value-display]:visible'), // Get units of visible holders. valunits = displays.toArray().map(function(el) { return [uval.call($(el)), $(el).attr('data-unit')] }); // Convert them from input return Convert.fromInput(measurement, valunits); } else if (!isNaN(value)) { // Write value to elements this.each(function() { var measurement = $(this).attr('data-measurement'), round = $(this).attr('data-round'), displays = $(this).find('[data-value-display]:visible'), units = displays.map(function() { return $(this).attr('data-unit'); }).toArray(); var values = Convert.toOutput(measurement, value, units, round); displays.each(function(index) { uval.call($(this), values[index]); }); }); } } }());
This plugin will be explained in the next section.
How it works...
HTML elements have no notion of measurement units. To support unit conversion, we added our own data attributes. These allow us to give a special meaning to certain elements—the specifics of which are then decided by our own code.
Our convention is that an element with a data-measurement
attribute will be used to display values and units for the specified measurement. For example, a field with the data-measurement="weight"
attribute will be used to display weight.
This element contains two types of subelements. The first type has a data-display-value
attribute, and displays the value of the measurement (always a number). The second type has a data-display-unit
attribute, and displays the unit of the measurement (for example, "kg"
). For measurements expressed in multiple units (for example, height can be expressed in the form of "5 ft 3 inch"), we can use multiple fields of both types.
When we change our unit system, setMeasurementUnits
adds additional data attributes to the following elements:
data-round
attributes are attached todata-measurement
elementsdata-unit attributes
containing the appropriate unit is added to thedata-display-value
elementsdata-display-unit
elements are filled with the appropriate units
As a result, $.unitval()
knows both the values and units displayed on every measurement element on our page. The function reads and converts the measurement to SI before returning it. We do all our calculations using the SI units. Finally, when calling $.unitval(si_value)
, our value is automatically converted to the appropriate units before display.
This system minimizes the amount of error-prone unit conversion code by recognizing that conversions are only really needed when reading user input and displaying output. Additionally, the data-driven approach allows us to omit conversions entirely from our code and focus on our application logic.