defmodule Localize.Inputs.Number.Unit do
@moduledoc """
Locale-derived display data for unit form inputs.
Mirrors `Localize.Inputs.Number.Symbols` but for the unit picker —
resolves the locale's measurement system, the preferred unit
list for a category in that system, and the full list of all
known units in the category. Unit display names are localized
via `Localize.Unit.display_name/2`.
Categories come from `Localize.Unit.known_categories/0` (e.g.
`"length"`, `"volume"`, `"mass"`). The preferred-by-system map
is a curated table per category — see `@preferred_by_system`
— because CLDR's per-region preference data is keyed by *usage*
(`:person_height`, `:road`, …) and value magnitude, not by a
simple "units that belong to system X" predicate. The curated
table covers the common categories; for everything else
`preferred_units/2` falls back to the full category list.
Prefixed units (SI prefixes like `decimeter`, `kilo*`, etc.)
are filtered out by default — see `:include_prefixed`.
"""
alias Localize.LanguageTag
@typedoc """
Locale display data resolved by `unit_for_locale/2`.
* `:locale` — the canonical CLDR locale id (atom).
* `:language_tag` — the full `t:Localize.LanguageTag.t/0`.
* `:category` — the unit category as a string (e.g. `"length"`).
* `:measurement_system` — the locale's default measurement
system as an atom (`:metric`, `:us`, `:uk`).
* `:unit` — the selected unit name as a string, or `nil` if
the caller didn't specify one.
* `:unit_display_name` — the localized display name of the
selected unit, or `nil`.
* `:preferred_units` — list of `{name, display_name}` tuples,
the units in this category that belong to the locale's
measurement system. Sorted in roughly small-to-large order
where the curated table provides it; alphabetical otherwise.
* `:all_units` — list of `{name, display_name}` tuples, every
known unit in the category. Alphabetical by name.
"""
@type t :: %__MODULE__{
locale: atom(),
language_tag: LanguageTag.t(),
category: String.t(),
measurement_system: atom(),
unit: String.t() | nil,
unit_display_name: String.t() | nil,
preferred_units: [{String.t(), String.t()}],
all_units: [{String.t(), String.t()}]
}
defstruct [
:locale,
:language_tag,
:category,
:measurement_system,
:unit,
:unit_display_name,
:preferred_units,
:all_units
]
# Curated per-(category × system) preferred unit list. CLDR's
# preference data is value-magnitude-dependent and keyed by
# usage, so a flat "preferred for picker UI" table is more
# useful here. Order is small-to-large within each system.
@preferred_by_system %{
"length" => %{
metric: ~w(millimeter centimeter meter kilometer),
us: ~w(inch foot yard mile),
uk: ~w(inch foot yard mile)
},
"volume" => %{
metric: ~w(milliliter centiliter liter),
us: ~w(fluid-ounce cup pint quart gallon),
uk: ~w(fluid-ounce-imperial pint-imperial gallon-imperial)
},
"mass" => %{
metric: ~w(milligram gram kilogram tonne),
us: ~w(ounce pound ton),
uk: ~w(ounce pound stone ton)
},
"area" => %{
metric: ~w(square-centimeter square-meter hectare square-kilometer),
us: ~w(square-inch square-foot square-yard acre square-mile),
uk: ~w(square-inch square-foot square-yard acre square-mile)
},
"temperature" => %{
metric: ~w(celsius kelvin),
us: ~w(fahrenheit celsius kelvin),
uk: ~w(celsius fahrenheit kelvin)
},
"duration" => %{
metric: ~w(second minute hour day week month year),
us: ~w(second minute hour day week month year),
uk: ~w(second minute hour day week month year)
},
"speed" => %{
metric: ~w(meter-per-second kilometer-per-hour),
us: ~w(mile-per-hour foot-per-second),
uk: ~w(mile-per-hour)
}
}
# SI/other prefixes we filter out by default. Keeping the
# picker uncluttered for everyday use; pass
# `include_prefixed: true` to disable.
@prefix_patterns ~w(
yocto zepto atto femto pico nano micro deci deca hecto
kilo mega giga tera peta exa zetta yotta
quetta ronna ronto quecto kibi mebi gibi tebi pebi exbi zebi yobi
)
@doc """
Resolves picker display data for the given locale + category.
### Arguments
* `locale` is a locale identifier (atom, string, or
`t:Localize.LanguageTag.t/0`). Defaults to
`Localize.get_locale/0`.
* `options` is a keyword list of options.
### Options
* `:category` is a unit category string from
`Localize.Unit.known_categories/0` (e.g. `"length"`).
**Required.**
* `:unit` is a specific unit name to also resolve display data
for. Optional.
* `:include_prefixed` — when `true`, SI-prefixed unit variants
(e.g. `decimeter`, `kilogram-square-meter`) are included in
the `:all_units` list. The default is `false` to keep
pickers manageable. The curated preferred lists are always
used as-is.
### Returns
* `{:ok, t()}` on success.
* `{:error, Exception.t()}` when the locale can't be parsed
(`Localize.InvalidLocaleError`) or the category is unknown.
### Examples
iex> {:ok, info} = Localize.Inputs.Number.Unit.unit_for_locale(:en, category: "length")
iex> info.measurement_system
:us
iex> {:ok, info} = Localize.Inputs.Number.Unit.unit_for_locale(:fr, category: "length")
iex> info.measurement_system
:metric
iex> {:ok, info} = Localize.Inputs.Number.Unit.unit_for_locale(:en, category: "length", unit: "meter")
iex> info.unit
"meter"
"""
@spec unit_for_locale(LanguageTag.t() | atom() | String.t() | nil, Keyword.t()) ::
{:ok, t()} | {:error, Exception.t()}
def unit_for_locale(locale \\ nil, options) do
category = Keyword.fetch!(options, :category)
selected = Keyword.get(options, :unit)
include_prefixed = Keyword.get(options, :include_prefixed, false)
with {:ok, language_tag} <- Localize.validate_locale(locale || Localize.get_locale()),
:ok <- validate_category(category) do
system = measurement_system(language_tag)
preferred = preferred_unit_names(category, system)
all = all_unit_names(category, include_prefixed)
{:ok,
%__MODULE__{
locale: language_tag.cldr_locale_id,
language_tag: language_tag,
category: category,
measurement_system: system,
unit: selected,
unit_display_name: selected && localized_name(selected, language_tag),
preferred_units: name_pairs(preferred, language_tag),
all_units: name_pairs(all, language_tag) |> Enum.sort_by(&elem(&1, 1))
}}
end
end
@doc """
Returns the list of unit categories known to CLDR.
### Returns
* A list of strings such as `["acceleration", "angle", "area", "length", "mass", ...]`.
"""
@spec known_categories() :: [String.t()]
def known_categories, do: Localize.Unit.known_categories()
@doc """
Returns the curated preferred-unit names for the given
`(category, system)` pair, or the full category list if no
curation exists for that pair.
### Returns
* A list of unit name strings.
"""
@spec preferred_unit_names(String.t(), atom()) :: [String.t()]
def preferred_unit_names(category, system) do
case Map.fetch(@preferred_by_system, category) do
{:ok, by_system} ->
Map.get(by_system, system, by_system[:metric] || all_unit_names(category, false))
:error ->
all_unit_names(category, false)
end
end
@doc """
Returns every known unit name in the given category.
### Arguments
* `category` — a category string from `known_categories/0`.
* `include_prefixed` — when `true`, includes SI-prefixed
variants (e.g. `decimeter`, `kilogram-square-meter`). The
default is `false`.
"""
@spec all_unit_names(String.t(), boolean()) :: [String.t()]
def all_unit_names(category, include_prefixed \\ false) do
units = Map.get(Localize.Unit.known_units_by_category(), category, [])
if include_prefixed do
Enum.sort(units)
else
units
|> Enum.reject(&prefixed?/1)
|> Enum.sort()
end
end
# ── internal ────────────────────────────────────────────────
defp validate_category(category) do
if category in known_categories() do
:ok
else
{:error,
ArgumentError.exception(
"unknown unit category #{inspect(category)} — known categories: #{inspect(known_categories())}"
)}
end
end
defp measurement_system(%LanguageTag{territory: territory}) when is_atom(territory) do
Localize.Unit.measurement_system_for_territory(territory)
rescue
_ -> :metric
end
defp name_pairs(unit_names, language_tag) do
Enum.map(unit_names, fn name -> {name, localized_name(name, language_tag)} end)
end
# CLDR's display_name is a fully-formatted unit pattern that
# depends on count/style. For a picker label we want the long
# noun form. Falls back to a humanised version of the unit
# name if CLDR doesn't carry the locale.
defp localized_name(name, language_tag) do
case Localize.Unit.display_name(name, locale: language_tag, style: :long) do
{:ok, formatted} when is_binary(formatted) and formatted != "" -> formatted
_ -> humanize(name)
end
end
defp humanize(name) do
name
|> String.replace("-", " ")
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
defp prefixed?(name) do
Enum.any?(@prefix_patterns, fn p -> String.starts_with?(name, p) end)
end
end