streetmix/streetmix

View on GitHub
client/src/util/width_units.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import {
  SETTINGS_UNITS_IMPERIAL,
  SETTINGS_UNITS_METRIC
} from '../users/constants'
import store from '../store'
import { formatNumber } from './number_format'
import type { UnitsSetting, WidthDefinition } from '@streetmix/types'

const IMPERIAL_CONVERSION_RATE = 0.3048
const METRIC_PRECISION = 3
const IMPERIAL_PRECISION = 3

const WIDTH_INPUT_CONVERSION = [
  { text: 'm', multiplier: 1 },
  { text: 'м', multiplier: 1 },
  { text: 'dm', multiplier: 1 / 10 },
  { text: 'cm', multiplier: 1 / 100 },
  { text: 'mm', multiplier: 1 / 1000 },
  { text: '"', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: '″', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: 'in', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: 'in.', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: 'inch', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: 'inches', multiplier: (1 * IMPERIAL_CONVERSION_RATE) / 12 },
  { text: "'", multiplier: 1 * IMPERIAL_CONVERSION_RATE },
  { text: '′', multiplier: 1 * IMPERIAL_CONVERSION_RATE },
  { text: 'ft', multiplier: 1 * IMPERIAL_CONVERSION_RATE },
  { text: 'ft.', multiplier: 1 * IMPERIAL_CONVERSION_RATE },
  { text: 'feet', multiplier: 1 * IMPERIAL_CONVERSION_RATE }
]

const IMPERIAL_VULGAR_FRACTIONS: Record<string, string> = {
  '.125': '⅛',
  '.25': '¼',
  '.375': '⅜',
  '.5': '½',
  '.625': '⅝',
  '.75': '¾',
  '.875': '⅞'
}

// https://www.jacklmoore.com/notes/rounding-in-javascript/
export function round (value: number, decimals: number): number {
  // Can't use exponentiation operators either, it'll still produce rounding
  // errors, so we stick with concatenating exponents as a string then
  // casting to Number to satisfy the type-checker.
  return Number(Math.round(Number(value + 'e' + decimals)) + 'e-' + decimals)
}

export function roundToPrecision (value: number): number {
  // Assumes metric
  return round(value, METRIC_PRECISION)
}

/**
 * Processes a string width input from user, returns a number
 */
export function processWidthInput (
  widthInput: string,
  units: UnitsSetting
): number {
  // Normalize certain input quirks. Spaces (more common at end or beginning
  // of input) go away, and comma-based decimals turn into period-based
  // decimals
  widthInput = widthInput.replace(/ /g, '')
  widthInput = widthInput.replace(/,/g, '.')

  // Replace vulgar fractions, such as ¼, with its decimal equivalent
  for (const i in IMPERIAL_VULGAR_FRACTIONS) {
    if (widthInput.includes(IMPERIAL_VULGAR_FRACTIONS[i])) {
      widthInput = widthInput.replace(
        new RegExp(IMPERIAL_VULGAR_FRACTIONS[i]),
        i
      )
    }
  }

  let width: number

  // The conditional makes sure we only split and parse separately when the
  // input includes ' as any character except the last
  if (
    widthInput.includes("'") &&
    widthInput.length > widthInput.indexOf("'") + 1
  ) {
    const splitInput = widthInput.split("'")
    // Add the ' to the first value so the parser knows to convert in feet,
    // not in unitless, when in metric
    splitInput[0] += "'"

    // Assuming anything coming after feet is going to be inches
    if (!splitInput[1].includes('"')) {
      splitInput[1] += '"'
    }

    width =
      parseStringForUnits(splitInput[0], units) +
      parseStringForUnits(splitInput[1], units)
  } else {
    width = parseStringForUnits(widthInput, units)
  }

  return width
}

/**
 * Formats a width to a "pretty" output and converts the value to the user's
 * current units settings (imperial or metric).
 */
export function prettifyWidth (
  width: number,
  units: UnitsSetting,
  locale: string
): string {
  let widthText = ''

  // LEGACY: Not all uses of this function pass in locale.
  // This is _not_ an optional value. After TypeScript conversion,
  // missing this parameter throws an error.
  if (!locale) {
    locale = store.getState().locale.locale
  }

  switch (units) {
    case SETTINGS_UNITS_IMPERIAL: {
      const imperialWidth = convertMetricMeasurementToImperial(width)
      widthText = getImperialMeasurementWithVulgarFractions(
        imperialWidth,
        locale
      ) // also converts to string
      widthText += '′'
      break
    }
    case SETTINGS_UNITS_METRIC:
    default:
      widthText = stringifyMeasurementValue(
        width,
        SETTINGS_UNITS_METRIC,
        locale
      )

      // Locale-specific units
      switch (locale) {
        // In Russian, the Cyrillic м is common in vernacular usage.
        // This is in defiance of SI, but should be friendlier.
        case 'ru':
          widthText += ' м'
          break
        // In Arabic, use the same character that the USDM uses for
        // metric units
        case 'ar':
          widthText += ' م'
          break
        default:
          widthText += ' m'
          break
      }

      break
  }

  return widthText
}

/**
 * Returns a measurement value as a locale-sensitive string without units
 * or formatting, and converts to the desired units, if necessary.
 * Used primarily when converting input box values to a simple number format
 */
export function stringifyMeasurementValue (
  value: number,
  units: UnitsSetting,
  locale: string
): string {
  let string = ''

  if (!value) return '0'

  // Force the use of Western Arabic numerals in Arabic locale
  if (locale === 'ar') {
    locale += '-u-nu-latn'
  }

  switch (units) {
    case SETTINGS_UNITS_IMPERIAL: {
      string = formatNumber(value, locale, {
        style: 'decimal',
        maximumFractionDigits: IMPERIAL_PRECISION
      })
      break
    }
    case SETTINGS_UNITS_METRIC:
    default: {
      string = formatNumber(value, locale, {
        style: 'decimal',
        maximumFractionDigits: METRIC_PRECISION
      })
      break
    }
  }

  return string
}

/**
 * Given a measurement value (stored internally in Streetmix as metric units),
 * return an imperial quantity up to three decimal point precision.
 */
export function convertMetricMeasurementToImperial (value: number): number {
  return roundToNearestEighth(
    round(value / IMPERIAL_CONVERSION_RATE, IMPERIAL_PRECISION)
  )
}

/**
 * Given a measurement, assumed to be in imperial units,
 * return a metric value up to three decimal point precision.
 */
export function convertImperialMeasurementToMetric (value: number): number {
  return round(value * IMPERIAL_CONVERSION_RATE, METRIC_PRECISION)
}

/**
 * Given a `width` definition (an object containing both metric and imperial
 * width values), return a numerical value in metric. If `units` is metric
 * then return the metric value as is. If `units` is imperial, convert the
 * imperial value to metric and return it.
 */
export function getWidthInMetric (
  width: WidthDefinition | undefined,
  units: UnitsSetting
): number | undefined {
  // If no value is provided, return undefined
  if (width === undefined) return

  if (units === SETTINGS_UNITS_IMPERIAL) {
    return convertImperialMeasurementToMetric(width.imperial)
  } else {
    return width.metric
  }
}

/**
 * Given a measurement, assumed to be in imperial units,
 * return a value rounded to the nearest (up or down) eighth.
 */
function roundToNearestEighth (value: number): number {
  return Math.round(value * 8) / 8
}

/**
 * Given a measurement value (assuming imperial units), return
 * a string formatted to use vulgar fractions, e.g. .5 => ½
 */
export function getImperialMeasurementWithVulgarFractions (
  value: number,
  locale: string
): string {
  // Determine if there is a vulgar fraction to display
  const remainder = value - Math.floor(value)
  const fraction = IMPERIAL_VULGAR_FRACTIONS[remainder.toString().substr(1)]

  if (fraction) {
    // Non-zero trailing number
    if (Math.floor(value)) {
      return (
        stringifyMeasurementValue(
          Math.floor(value),
          SETTINGS_UNITS_IMPERIAL,
          locale
        ) + fraction
      )
    } else {
      return fraction
    }
  }

  // Otherwise, just return the stringified value without fractions
  return stringifyMeasurementValue(value, SETTINGS_UNITS_IMPERIAL, locale)
}

/**
 * Given a width in any unit (including no unit), parses for units and returns
 * value multiplied by the appropriate multiplier.
 */
function parseStringForUnits (widthInput: string, units: UnitsSetting): number {
  if (widthInput.includes('-')) {
    widthInput = widthInput.replace(/-/g, '') // Dashes would mean negative in the parseFloat
  }

  let width = Number.parseFloat(widthInput)

  if (width) {
    let multiplier = 1
    let precision = METRIC_PRECISION

    if (units === SETTINGS_UNITS_IMPERIAL) {
      multiplier = 1 * IMPERIAL_CONVERSION_RATE
      precision = IMPERIAL_PRECISION
    }

    for (const converter of WIDTH_INPUT_CONVERSION) {
      if (widthInput.match(new RegExp('[\\d\\.]' + converter.text + '$'))) {
        multiplier = converter.multiplier
        break
      }
    }
    width *= multiplier
    return round(width, precision)
  } else {
    return 0 // Allows for leading zeros, like 0'7"
  }
}