// Metric is what this app stores, sends and holds in form state. This module is one // of exactly two places that changes: it turns a metric number into the string a // diver reading in feet and psi expects, and turns what they type back into metric. // `UnitNumberInput` is the other edge, and it is built out of what's here. // // Nothing in this file reaches for React or for context. Components read the // preference once through `useUnits` and pass it down, which is what keeps the gas // maths in `lib/dive-mixtures.ts`, the Zod schemas, `prefillFromLastDive` and the // import-apply path unit-blind - they all run on metric values and never learn the // diver's preference at all. /** The two systems the app renders in. Mirrors the API's `UnitSystem` StrEnum. */ export const UNIT_SYSTEMS = ["metric", "imperial"] as const; export type UnitSystem = (typeof UNIT_SYSTEMS)[number]; /** * How each system is named in the settings picker. * * Spelled out as the dimensions themselves rather than as "Metric"/"Imperial" alone, * because the word is not the question - which units the numbers will be in is, and a * diver who thinks in psi should not have to switch the toggle to find out whether * this app agrees with them about what "imperial" covers. */ export const UNIT_SYSTEM_LABELS: Record = { metric: "Metric (m · °C · bar · kg · L)", imperial: "Imperial (ft · °F · psi · lb · cuft)", }; // The conversion constants, every one an exact definition rather than a rounded // figure: the foot has been exactly 0.3048 m since 1959, the pound exactly // 0.45359237 kg, and the psi is exactly one pound-force over one square inch, which // resolves to the pascal figure below. Written out so the round trips in // `units.test.ts` hold to the last digit a double can carry, and so a reader can // check them against a standard rather than against whoever typed them. /** Metres in one international foot, exactly. */ export const METERS_PER_FOOT = 0.3048; /** Kilograms in one avoirdupois pound, exactly. */ export const KG_PER_POUND = 0.45359237; /** Litres in one cubic foot, exactly (0.3048³ m³). */ export const LITERS_PER_CUBIC_FOOT = 28.316846592; // Written as its own definition rather than as a decimal literal: a psi is one // pound-force over one square inch, and the pound-force is the exact pound times // the exact standard gravity. Typed out as a number it is 6894.757293168361..., and // a truncated copy of that is a silently wrong constant. /** Pascals in one psi, from the exact pound, gravity and inch. */ export const PASCALS_PER_PSI = (KG_PER_POUND * 9.80665) / 0.0254 ** 2; /** Psi in one bar, from the exact pascal definition. ~14.5038. */ export const PSI_PER_BAR = 100000 / PASCALS_PER_PSI; /** Feet in one international mile, exactly. */ export const FEET_PER_MILE = 5280; // Geometric feet, not feet of seawater. 1 ft is a length, while "33 fsw per // atmosphere" is a pressure gauge's calibration - a different quantity that happens // to be quoted in feet. Depth, visibility, altitude and drift all use the one factor, // which is also the choice Subsurface makes. /** Metres to feet. */ export const metersToFeet = (meters: number): number => meters / METERS_PER_FOOT; /** Feet to metres. */ export const feetToMeters = (feet: number): number => feet * METERS_PER_FOOT; /** Celsius to Fahrenheit. */ export const celsiusToFahrenheit = (celsius: number): number => (celsius * 9) / 5 + 32; /** Fahrenheit to Celsius. */ export const fahrenheitToCelsius = (fahrenheit: number): number => ((fahrenheit - 32) * 5) / 9; /** Kilograms to pounds. */ export const kgToPounds = (kg: number): number => kg / KG_PER_POUND; /** Pounds to kilograms. */ export const poundsToKg = (pounds: number): number => pounds * KG_PER_POUND; /** Bar to psi. */ export const barToPsi = (bar: number): number => bar * PSI_PER_BAR; /** Psi to bar. */ export const psiToBar = (psi: number): number => psi / PSI_PER_BAR; /** Metres in one international nautical mile, exactly. */ export const METERS_PER_NAUTICAL_MILE = 1852; /** Litres to cubic feet. */ export const litersToCubicFeet = (liters: number): number => liters / LITERS_PER_CUBIC_FOOT; /** Cubic feet to litres. */ export const cubicFeetToLiters = (cubicFeet: number): number => cubicFeet * LITERS_PER_CUBIC_FOOT; // Standard atmospheres in a bar. const BAR_PER_ATMOSPHERE = 1.01325; /** * A cylinder's gas capacity in whole cubic feet: its water capacity times its working * pressure in atmospheres, compressibility ignored - Subsurface's own arithmetic. * Ideal gas over-reads a real cylinder and the makers' names are nominal, so it * gives an AL100 105 and an HP80 84; a gear item matching a named preset shows that * name instead (`vesselFactParts`). * Only for a vessel recording both facts, which a gear item can and a dive's * cylinder cannot. */ export function cylinderCubicFeet( volumeLiters: number, workingPressureBar: number, ): number { return Math.round( litersToCubicFeet(volumeLiters * (workingPressureBar / BAR_PER_ATMOSPHERE)), ); } /** * Every measurement this module knows how to convert or label. * * Keyed by dimension rather than by field, so a second depth column or a third * pressure needs no entry here - and so per-dimension preferences, if they are ever * wanted, are an additive change to `UnitSystem`'s use rather than a rewrite. * * Deliberately absent: ppO₂ and surface pressure (bar in both systems, as every dive * computer shows them), tank volume (litres is water capacity and cubic feet is gas * at a rated pressure - a dive's cylinder records no such pressure, so the dive side * stays in litres, and only a gear item, which records its working pressure, can be * named in cubic feet: `cylinderCubicFeet`), O₂/He percentages, CNS/OTU, coordinates, * duration, and wind speed (knots in both, as boats and marine forecasts give it). */ export type Dimension = | "depth" | "temperature" | "weight" | "pressure" | "visibility" | "altitude" | "sac" | "rmv" | "gasVolume"; /** * The dimensions a diver types into, which is what `UnitNumberInput` accepts. * * The rest are derived figures the API computes and this app only renders. * * An array rather than a type alone, because two things need the list at * runtime: `lib/entry-units.ts` filters a stored override record's keys against * it, and the dive form hangs one unit toggle off each entry. `DIMENSIONS` below * is module-private and holds the derived figures too, so neither could read the * list off that. `satisfies` keeps every entry a real `Dimension` while leaving * the literal tuple intact for `EntryDimension` to be read back off it. */ export const ENTRY_DIMENSIONS = [ "depth", "temperature", "weight", "pressure", "visibility", "altitude", ] as const satisfies readonly Dimension[]; export type EntryDimension = (typeof ENTRY_DIMENSIONS)[number]; interface DimensionSpec { /** Short label, as it follows a value and as it sits in a form label's parens. */ label: Record; /** * Spoken form, for the chart descriptions a screen reader reads aloud: "ft" is * read as a word rather than as a unit, and "°C" not at all. */ word: Record; /** What sits between value and label. Only temperature attaches its degrees. */ separator: string; /** Metric value to its imperial counterpart. */ toImperial: (metric: number) => number; /** Imperial value back to metric. */ toMetric: (imperial: number) => number; /** * Decimals in metric, or `null` for "up to two, trailing zeros trimmed" - which * is what every value the API records at two decimals wants. */ metricDecimals: number | null; /** Decimals in imperial. Whole numbers for everything but the two rates. */ imperialDecimals: number; /** * Whether the metric column stores whole numbers, so imperial entry has to commit * a whole one. True for `visibility` and `altitude`, both `Integer` in the DB. */ metricIsInteger: boolean; } const DIMENSIONS: Record = { depth: { label: { metric: "m", imperial: "ft" }, word: { metric: "meters", imperial: "feet" }, separator: " ", toImperial: metersToFeet, toMetric: feetToMeters, metricDecimals: null, imperialDecimals: 0, metricIsInteger: false, }, temperature: { label: { metric: "°C", imperial: "°F" }, word: { metric: "degrees Celsius", imperial: "degrees Fahrenheit" }, // The one attached unit: "24°C" is how a temperature is written everywhere, // including on the dive computers these readings come off. separator: "", toImperial: celsiusToFahrenheit, toMetric: fahrenheitToCelsius, metricDecimals: null, imperialDecimals: 0, metricIsInteger: false, }, weight: { label: { metric: "kg", imperial: "lb" }, word: { metric: "kilograms", imperial: "pounds" }, separator: " ", toImperial: kgToPounds, toMetric: poundsToKg, metricDecimals: null, imperialDecimals: 0, metricIsInteger: false, }, pressure: { label: { metric: "bar", imperial: "psi" }, word: { metric: "bar", imperial: "psi" }, separator: " ", toImperial: barToPsi, toMetric: psiToBar, metricDecimals: null, imperialDecimals: 0, metricIsInteger: false, }, visibility: { label: { metric: "m", imperial: "ft" }, word: { metric: "meters", imperial: "feet" }, separator: " ", toImperial: metersToFeet, toMetric: feetToMeters, // Whole metres in both directions: visibility is an estimate, the column is an // `Integer`, and the form has always stepped by 1. metricDecimals: 0, imperialDecimals: 0, metricIsInteger: true, }, altitude: { label: { metric: "m", imperial: "ft" }, word: { metric: "meters", imperial: "feet" }, separator: " ", toImperial: metersToFeet, toMetric: feetToMeters, // `Integer` for the same reason as visibility, plus one of its own: the // computers that care about altitude bucket it into 300 m bands anyway. metricDecimals: 0, imperialDecimals: 0, metricIsInteger: true, }, sac: { label: { metric: "bar/min", imperial: "psi/min" }, word: { metric: "bar per minute", imperial: "psi per minute" }, separator: " ", toImperial: barToPsi, toMetric: psiToBar, metricDecimals: null, // A whole psi/min would be one significant figure for a typical dive, so this // rate keeps a decimal where the scalars don't. imperialDecimals: 1, metricIsInteger: false, }, rmv: { label: { metric: "L/min", imperial: "cuft/min" }, word: { metric: "liters per minute", imperial: "cubic feet per minute" }, separator: " ", toImperial: litersToCubicFeet, toMetric: cubicFeetToLiters, metricDecimals: null, // Two, because a cubic foot is 28 litres: an RMV a diver reads as 18 L/min is // 0.64 cuft/min, and one decimal would round two real rates together. imperialDecimals: 2, metricIsInteger: false, }, gasVolume: { label: { metric: "L", imperial: "cuft" }, word: { metric: "liters", imperial: "cubic feet" }, separator: " ", toImperial: litersToCubicFeet, toMetric: cubicFeetToLiters, metricDecimals: null, imperialDecimals: 1, metricIsInteger: false, }, }; /** How many decimals a metric value renders at, up to two, before trimming. */ const METRIC_DECIMALS = 2; /** * Rounds to `decimals` places. * * One rounding path for display and for entry, so a value that renders as 98 ft * commits the metres that render back as 98 ft. Ties go up, as `Math.round` does. */ export function roundTo(value: number, decimals: number): number { const factor = 10 ** decimals; return Math.round(value * factor) / factor; } // Up to two decimals with trailing zeros dropped. A value the API recorded at two // decimals renders exactly as it always has ("30.52"), an imported value carrying // more is cut to the precision the API would have stored ("18.288" -> "18.29"), and // the corpus's float noise stops leaking into the page ("28.000000000000004" -> // "28"). function trimmed(value: number): string { return String(roundTo(value, METRIC_DECIMALS)); } // The fewest decimals that land within 2^-22 of `value`, relative. // // Exporters that hold a figure as a float32 print it to more digits than it has, // and often a unit or two off the nearest float to the decimal: Shearwater writes // 21.8 as "21.8000011" and UDDF 2.7 as "2.70000029", neither of which is // `Math.fround` of the decimal, so a float32 round-trip test would keep them. // That tolerance is two to four float32 ulps, so it absorbs that noise and the // double kind ("0.30000000000000004"), while a decimal recorded at six // significant digits or fewer - "21.815", "18.288" - is further than that from // every shorter one and keeps its digits. A seventh digit can be lost next to a // round number (9.999999 shows as 10), which no depth, temperature or pressure // is recorded finely enough to hit. // Only fractional digits are dropped, never the integer part. const FLOAT32_NOISE = 2 ** -22; export function withoutFloatNoise(value: number): string { const tolerance = Math.abs(value) * FLOAT32_NOISE; for (let decimals = 0; decimals <= 15; decimals++) { const candidate = Number(value.toFixed(decimals)); if (Math.abs(candidate - value) <= tolerance) return String(candidate); } return String(value); } /** The value `metric` is displayed as in `units` - unconverted in metric mode. */ export function toDisplayUnits( metric: number, dimension: Dimension, units: UnitSystem, ): number { return units === "imperial" ? DIMENSIONS[dimension].toImperial(metric) : metric; } /** The metric value behind a number shown in `units` - unconverted in metric mode. */ export function toMetricUnits( display: number, dimension: Dimension, units: UnitSystem, ): number { return units === "imperial" ? DIMENSIONS[dimension].toMetric(display) : display; } /** Whether this dimension's metric column holds whole numbers. */ export function isIntegerDimension(dimension: Dimension): boolean { return DIMENSIONS[dimension].metricIsInteger; } /** * The metric value to store for a number the diver typed in `units`. * * Rounded after the conversion is inverted, never before, which is what makes entry * stable: 98 ft becomes 29.87 m and renders back as 98 ft, because a 2-decimal * metric commit is off by at most 0.005 of a unit and that is far under half an * imperial display unit for every dimension here. Integer dimensions commit whole * metres, and that one *is* lossy - 50 ft lands on 15 m and re-renders as 49 ft. */ export function toCommittedMetric( display: number, dimension: EntryDimension, units: UnitSystem, ): number { const metric = toMetricUnits(display, dimension, units); return isIntegerDimension(dimension) ? Math.round(metric) : roundTo(metric, METRIC_DECIMALS); } export interface FormatOptions { /** * Fixed decimals for the **metric** rendering, overriding the dimension's own. * * Imperial is deliberately unaffected. A whole foot is already finer than a * tenth of a metre and a hundredth of a cubic foot finer than a tenth of a litre, * so a call that coarsens the metric figure on purpose - Home's * whole-metre depth, a period-average RMV - has nothing left to coarsen on the * other side. */ decimals?: number; } /** * The bare number a metric value shows as in `units`, with no unit after it. * * For the places that style the value and its unit separately, and for * `UnitNumberInput`, whose `` holds a number and gets its unit from a label. */ export function displayNumber( metric: number, dimension: Dimension, units: UnitSystem, { decimals }: FormatOptions = {}, ): string { const shown = toDisplayUnits(metric, dimension, units); const spec = DIMENSIONS[dimension]; const places = units === "imperial" ? spec.imperialDecimals : (decimals ?? spec.metricDecimals); return places === null ? trimmed(shown) : roundTo(shown, places).toFixed(places); } /** The short unit label for a dimension - "m"/"ft", "°C"/"°F", "bar/min"/"psi/min". */ export function unitLabel(dimension: Dimension, units: UnitSystem): string { return DIMENSIONS[dimension].label[units]; } /** * What sits between a value and this dimension's label - a space, or nothing at * all for temperature, which is written "24°C" everywhere including on the dive * computers these readings come off. * * Exported for the profile chart, which works in already-converted display space * and so builds its own strings rather than calling a formatter that would convert * a second time. Everything else gets the separator through `formatDepth` and its * siblings and never has to know it exists. */ export function unitSeparator(dimension: Dimension): string { return DIMENSIONS[dimension].separator; } /** * The spoken unit for a dimension - "feet", "degrees Fahrenheit", "psi". * * For accessible chart descriptions, where the visible label's "ft" is read as a * word and "°C" is skipped entirely. */ export function unitWord(dimension: Dimension, units: UnitSystem): string { return DIMENSIONS[dimension].word[units]; } /** A metric value with its unit: `"30.52 m"`, `"100 ft"`, `"24°C"`. */ function formatValue( metric: number, dimension: Dimension, units: UnitSystem, options?: FormatOptions, ): string { const spec = DIMENSIONS[dimension]; return `${displayNumber(metric, dimension, units, options)}${spec.separator}${spec.label[units]}`; } /** A depth in metres, as `"30.52 m"` or `"100 ft"`. */ export function formatDepth( meters: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(meters, "depth", units, options); } /** * How fine a foot is written when two depths are being compared. A tenth, which is * 3 cm - the nearest imperial has to the centimetre the metric side stores. */ const COMPARABLE_IMPERIAL_DECIMALS = 1; /** * Rounds *down* to `decimals` places. * * The scaled value is settled before it is floored, because scaling is itself * inexact: `9.2 * 100` is 919.9999999999999 and `Math.floor` takes a whole step off * it, so a limit already on the grid would print a centimetre shallower than it is. * Six decimals is far below the grid step and far above the error. Settling the * *unscaled* value instead does not work - rounding it to a couple of places finer * carries 39.46996 up to 39.47, which the floor then keeps, and a limit that rounds * up names a depth past itself. */ function floorTo(value: number, decimals: number): number { const factor = 10 ** decimals; return Math.floor(roundTo(value * factor, 6)) / factor; } /** * A depth written at the scale two depths can be told apart at: two decimals in * metric, a tenth of a foot in imperial. * * `formatDepth` writes imperial as whole feet, which is the resolution a single * reading is honest at and too coarse to state a comparison in - a gas breached by a * few centimetres prints the same number on both sides, and the sentence reads as a * depth past itself. * * `floor` rounds down rather than to nearest, for a value that is itself a ceiling: a * limit rounded up names a depth that is past it, and it is the rounding, not the * limit, that then collides with the depth beside it. * * What that closes: a depth recorded to the centimetre and a floored limit sit on one * grid, so a breach between them always prints two different numbers. What it leaves: * a parsed depth carries whatever precision the computer wrote, and 33.7512 m against * EAN32's 33.75 m limit prints one number twice; and a tenth of a foot is 3 cm, so an * imperial breach can collide depending where in that tenth the limit falls - air at * 56.67 m gives "185.9 ft is past this mix's 185.9 ft working limit", while EAN28 * breached by 9 mm gives 131.3 against 131.2. Hundredths of a foot would close the * imperial half, at two digits on every MOD the app shows, which is precision no * cylinder is analysed to. */ export function formatComparableDepth( meters: number, units: UnitSystem, { floor = false }: { floor?: boolean } = {}, ): string { const shown = toDisplayUnits(meters, "depth", units); const places = units === "imperial" ? COMPARABLE_IMPERIAL_DECIMALS : METRIC_DECIMALS; const value = floor ? floorTo(shown, places) : roundTo(shown, places); return `${value}${unitSeparator("depth")}${unitLabel("depth", units)}`; } /** A temperature in Celsius, as `"23.89°C"` or `"75°F"`. */ export function formatTemperature( celsius: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(celsius, "temperature", units, options); } /** A weight in kilograms, as `"6 kg"` or `"13 lb"`. */ export function formatWeight( kg: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(kg, "weight", units, options); } /** A cylinder pressure in bar, as `"206.84 bar"` or `"3000 psi"`. */ export function formatPressure( bar: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(bar, "pressure", units, options); } /** A visibility in metres, whole in both systems: `"15 m"` or `"49 ft"`. */ export function formatVisibility(meters: number, units: UnitSystem): string { return formatValue(meters, "visibility", units); } /** An altitude in metres, whole in both systems: `"372 m"` or `"1220 ft"`. */ export function formatAltitude(meters: number, units: UnitSystem): string { return formatValue(meters, "altitude", units); } /** A surface air consumption in bar/min, as `"0.82 bar/min"` or `"11.9 psi/min"`. */ export function formatSac( barPerMinute: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(barPerMinute, "sac", units, options); } /** A respiratory minute volume in L/min, as `"18.24 L/min"` or `"0.64 cuft/min"`. */ export function formatRmv( litersPerMinute: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(litersPerMinute, "rmv", units, options); } /** A volume of gas in litres, as `"3618 L"` or `"127.8 cuft"`. */ export function formatGasVolume( liters: number, units: UnitSystem, options?: FormatOptions, ): string { return formatValue(liters, "gasVolume", units, options); } /** A wind speed in metres per second as whole knots in either system: `"11 kn"`. */ export function formatWindSpeed(metersPerSecond: number): string { const knots = (metersPerSecond * 3600) / METERS_PER_NAUTICAL_MILE; return `${Math.round(knots)} kn`; } /** * A metric bound as the display number that gates the same range in `units`. * * Native `min`/`max` are declared in metres and Celsius by the caller, because that * is what the Zod schema and the DB `CHECK` behind it are written in - so the * browser's spinner needs the converted pair, and it has to agree with what the * diver sees. Rounded *inward* (a minimum up, a maximum down) so the spinner can * never offer a value the metric bound would reject: -450 m becomes -1476 ft, which * is -449.9 m back, rather than the -1477 ft that would be -450.2 m. */ export function displayBound( metric: number, dimension: EntryDimension, units: UnitSystem, side: "min" | "max", ): number { // Metric passes through untouched, like every other value in metric mode - the // bound is already in the units the schema wrote it in. if (units === "metric") return metric; const shown = toDisplayUnits(metric, dimension, units); return side === "min" ? Math.ceil(shown) : Math.floor(shown); }