Measurement utilities
The zero-dependency conversion and formatting engine behind the field, exported for frontend use.
@10x-media/fields/measurement/utils is the conversion and formatting engine the field itself runs, exported standalone. It has no Payload imports, so it is safe in any bundle: server, client, worker.
import {
convert,
formatMeasurement,
decompose,
compose,
resolveDisplayUnit,
systemForLocale,
createEngine,
unitsOfDimension,
UNITS,
} from '@10x-media/fields/measurement/utils'Format
formatMeasurement(4.5359, {
storageUnit: 'kg',
displayUnit: 'lb',
locale: 'en-US',
})
// '10 lb'formatMeasurement(value, options) converts a stored value from storageUnit to displayUnit and formats it with Intl.NumberFormat, so unit names and number formatting localize for free. unitDisplay ('short' default, 'long', 'narrow') controls the unit form; precision overrides display fraction digits per unit (a plain Partial<Record<UnitId, number>>, independent from the field's own precision modes covered in Precision, which resolve a mode's knobs down to this same shape before calling in). Displaying a compound unit (ft-in, st-lb) formats both parts and joins them with Intl.ListFormat. This top-level function only knows the built-in units; call createEngine(custom).formatMeasurement for a field with custom units (see below).
Convert
convert(100, 'cm', 'm') // 1
convert(0, 'c', 'f') // 32convert(value, from, to) converts between two scalar units of the same dimension via that dimension's canonical unit (kg, m, l, celsius, m/s). Converting across dimensions, convert(1, 'kg', 'm'), throws.
Compound values
decompose(180.34, 'cm', 'ft-in') // { major: 5, minor: 11 } (5 ft 11 in)
compose({ major: 5, minor: 11 }, 'ft-in', 'cm') // 180.34decompose(value, from, compound, minorDigits?) splits a stored scalar into a compound unit's major/minor parts, rounding the minor part before deriving the major so a value like 5 ft 11.996 in carries to 6 ft 0 in instead of printing an impossible 5 ft 12 in. compose(parts, compound, to) is the inverse, folding major/minor parts back into a single scalar in to.
Locale resolution
systemForLocale('en-US') // 'us'
systemForLocale('en-GB') // 'uk'
systemForLocale('de-DE') // 'metric'systemForLocale(locale) maps a BCP 47 locale to a measurement system, off a five-entry table vendored from CLDR supplemental measurement data (no shipped Intl API exposes it): US system for the US and Liberia, UK system for Great Britain and Myanmar, metric everywhere else.
import { presets } from '@10x-media/fields/measurement/utils'
resolveDisplayUnit({
dimension: 'mass',
system: systemForLocale('en-GB'),
localeDefaults: presets.bodyWeight.localeDefaults,
units: presets.bodyWeight.units,
})
// 'st-lb'resolveDisplayUnit(args) is the exact resolution chain the field itself runs: preferenceUnit, then fallbackUnit, then registryDefault, then localeDefaults[system], then DIMENSION_LOCALE_DEFAULTS[dimension][system], then units[0] as the floor. Every argument except dimension and units is optional, so a frontend recipe that only needs locale-based resolution passes system and localeDefaults (or leans on the built-in table alone). DIMENSION_LOCALE_DEFAULTS is exported directly for the five built-in dimensions (mass, length, volume, temperature, speed); a custom dimension has no entry there and falls through to units[0] once localeDefaults is exhausted.
Dimensions and units
unitsOfDimension(dimension) lists every built-in unit (scalar and compound) of a dimension:
unitsOfDimension('mass') // ['g', 'kg', 'oz', 'lb', 'st', 'st-lb']| Dimension | Units |
|---|---|
| mass | g, kg, oz, lb, st, st-lb |
| length | mm, cm, m, km, in, ft, yd, mi, ft-in |
| volume | ml, l, fl-oz, gal |
| temperature | c, f |
| speed | km/h, mph, m/s |
UNITS carries each built-in scalar unit's dimension, conversion factor, Intl.NumberFormat unit identifier, and short symbol:
| Unit | Dimension | Short label |
|---|---|---|
g, kg | mass | g, kg |
oz, lb, st | mass | oz, lb, st |
mm, cm, m, km | length | mm, cm, m, km |
in, ft, yd, mi | length | in, ft, yd, mi |
ml, l | volume | ml, l |
fl-oz, gal | volume | fl oz, gal |
c, f | temperature | °C, °F |
km/h, mph, m/s | speed | km/h, mph, m/s |
precisionFor(unit, overrides?) returns a unit's display fraction digits, the same lookup formatMeasurement uses internally.
Custom units
Every function above only knows the built-in units. A field (or a frontend rendering one) with custom units needs an engine built over the same config:
import { createEngine } from '@10x-media/fields/measurement/utils'
const engine = createEngine({
units: {
nmi: { dimension: 'length', factor: 1852, intlUnit: null, shortLabel: 'nmi' },
},
})
engine.convert(1852, 'm', 'nmi') // 1
engine.formatMeasurement(2778, { storageUnit: 'm', displayUnit: 'nmi', locale: 'en-US' })
// '1.5 nmi', the plain-decimal + shortLabel fallback since nmi has no intlUnitcreateEngine(custom?) merges the built-in tables with custom.units and custom.dimensions and returns { convert, decompose, compose, formatMeasurement, unitLabel, precisionFor, dimensionOf, isScalarUnit, isCompoundUnit, unitsOfDimension }, the same shape the top-level functions wrap. Called with no argument it's byte-identical to the built-in functions; it's how the field itself gets a zero-custom fast path.
A whole custom dimension works the same way, declaring one custom unit as the canonical (factor: 1, no offset):
const pressure = createEngine({
units: {
pa: { dimension: 'pressure', factor: 1, intlUnit: null, shortLabel: 'Pa' },
bar: { dimension: 'pressure', factor: 100000, intlUnit: null, shortLabel: 'bar' },
},
dimensions: { pressure: { canonicalUnit: 'pa' } },
})
pressure.convert(100000, 'pa', 'bar') // 1Custom unit and dimension ids can't collide with a built-in one, and a custom dimension's canonicalUnit must be a custom unit declared for that dimension with factor: 1 and no offset; createEngine throws otherwise. Compound units (ft-in, st-lb) stay built-in, so decompose/compose on a custom storage unit bridge through the compound's built-in major unit.
Frontend example
Formatting an athlete's stored weight (kg, the bodyWeight preset's storage unit) per visitor locale, in a Next.js server component:
import { headers } from 'next/headers'
import {
formatMeasurement,
presets,
resolveDisplayUnit,
systemForLocale,
} from '@10x-media/fields/measurement/utils'
export const AthleteWeight = async ({ weight }: { weight: number }) => {
const locale = (await headers()).get('accept-language')?.split(',')[0] ?? 'en-US'
const displayUnit = resolveDisplayUnit({
dimension: 'mass',
localeDefaults: presets.bodyWeight.localeDefaults,
system: systemForLocale(locale),
units: presets.bodyWeight.units,
})
return <span>{formatMeasurement(weight, { storageUnit: 'kg', displayUnit, locale })}</span>
}A US visitor reads "154 lb", a UK visitor "11 st 0 lb", everyone else "70 kg", all from the one stored 70.