Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implemented Number#as_human #61

Merged
merged 3 commits into from
Aug 16, 2015
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions lib/crutches/format/number.ex
Original file line number Diff line number Diff line change
Expand Up @@ -418,4 +418,260 @@ defmodule Crutches.Format.Number do
def as_percentage!(number, opts) do
as_percentage(number, opts)
end

@doc ~S"""
Formats and approximates `number` for human readability.

`1200000000` becomes `"1.2 Billion"`. This is useful as larger numbers become
harder to read.

See `as_human_size` if you want to print a file size.

You can also define you own unit-quantifier names if you want to use other
decimal units (eg.: 1500 becomes "1.5 kilometers", 0.150 becomes
“150 milliliters”, etc). You may define a wide range of unit quantifiers, even
fractional ones (centi, deci, mili, etc).

# Options

- `:locale` (atom) --- Locale to be used for formatting. *Default:*
current locale.
- `:precision` (integer) --- Precision of the number. *Default:* `3`
- `:significant` - If true, precision will be the # of significant_digits. If
false, the # of fractional digits (defaults to true)
- `:separator` (string) --- Separator between the fractional and integer
digits. *Default:* `"."`
- `:delimiter` (string) --- Thousands delimiter. *Default:* `""`
-`:units` (keyword list/string) --- Keyword list of unit quantifier names,
*or* a string containing an i18n scope pointing to it.
- `:format` (string) --- Format of the output string. `%u` is the quantifier,
`%n` is the number. *Default:* `"%n %u"`

# i18n

This function takes a keyword list of quantifier names to use for formatting.
It supports the following keys:

- `:unit`
- `:ten`
- `:hundred`
- `:thousand`
- `:million`
- `:billion`
- `:trillion`
- `:quadrillion`
- `:deci`
- `:centi`
- `:milli`
- `:micro`
- `:nano`
- `:pico`
- `:femto`

# Examples

iex> Number.as_human(123)
"123"

iex> Number.as_human(1234)
"1.23 Thousand"

iex> Number.as_human(12345)
"12.3 Thousand"

iex> Number.as_human(1234567)
"1.23 Million"

iex> Number.as_human(1234567890)
"1.23 Billion"

iex> Number.as_human(1234567890123)
"1.23 Trillion"

iex> Number.as_human(1234567890123456)
"1.23 Quadrillion"

iex> Number.as_human(1234567890123456789)
"1230 Quadrillion"

iex> Number.as_human(489939, precision: 2)
"490 Thousand"

iex> Number.as_human(489939, precision: 4)
"489.9 Thousand"

iex> Number.as_human(1234567, precision: 4, significant: false)
"1.2346 Million"

iex> Number.as_human(1234567, precision: 1, separator: ",", significant: false)
"1,2 Million"

iex> Number.as_human(500000000, precision: 5)
"500 Million"

iex> Number.as_human(12345012345, significant: false)
"12.345 Billion"

iex> Number.as_human!("abc")
** (ArithmeticError) bad argument in arithmetic expression
"""
@as_human [
valid: [:locale, :precision, :significant, :separator, :delimiter,
:strip_insignificant_zeros, :units, :format],
defaults: [
precision: 3,
significant: true,
separator: ".",
delimiter: "",
strip_insignificant_zeros: true,
units: [
quadrillion: "Quadrillion",
trillion: "Trillion",
billion: "Billion",
million: "Million",
thousand: "Thousand",
hundred: "",
ten: "",
unit: "",
deci: "deci",
centi: "centi",
milli: "milli",
micro: "micro",
nano: "nano",
pico: "pico",
femto: "femto"
],
format: "%n %u"
]
]

def as_human(number, opts \\ [])
def as_human(number, opts) when is_number(number) do
opts = Option.combine!(opts, @as_human)
{exp, unit, sign} = closest_size_and_sign(number)

precision = opts[:precision]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

precision = Math.max(opts[:precision], 0)

:shipit:


if precision < 0 do
precision = 0
end

delimited_opts = Keyword.take(opts, @as_delimited[:valid])

fract_num =
abs(number) / :math.pow(10, exp)
|> rounded_or_significant(opts[:significant], precision)
|> strip_trailing_zeros(opts[:strip_insignificant_zeros])
|> as_delimited(delimited_opts)

if sign < 0 do
fract_num = "-" <> fract_num
end

format_as_human(fract_num, opts[:units][unit], opts[:format])
end

defp closest_size_and_sign(number) when is_number(number) do
tenth_exp =
number
|> abs
|> :math.log10
|> trunc

sign = number_sign(number)

cond do
tenth_exp >= 15 -> {15, :quadrillion, sign}
tenth_exp >= 12 -> {12, :trillion, sign}
tenth_exp >= 9 -> {9, :billion, sign}
tenth_exp >= 6 -> {6, :million, sign}
tenth_exp >= 3 -> {3, :thousand, sign}
tenth_exp >= 2 -> {0, :hundred, sign}
tenth_exp >= 1 -> {0, :ten, sign}
tenth_exp >= 0 -> {0, :unit, sign}
tenth_exp < 0 && tenth_exp >= -1 -> {-1, :deci, sign}
tenth_exp < -1 && tenth_exp >= -2 -> {-2, :centi, sign}
tenth_exp < -2 && tenth_exp >= -3 -> {-3, :milli, sign}
tenth_exp < -3 && tenth_exp >= -6 -> {-6, :micro, sign}
tenth_exp < -6 && tenth_exp >= -9 -> {-9, :nano, sign}
tenth_exp < -9 && tenth_exp >= -12 -> {-12, :pico, sign}
tenth_exp < -12 -> {-15, :femto, sign}
end
end

defp number_sign(number) when is_number(number) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno, I like function heads better. What do you think?

defp number_sign(number) when is_number(number) and number < 0, do: -1
defp number_sign(number) when is_number(number), do: 1

cond do
number >= 0 -> 1
true -> -1
end
end

defp rounded_or_significant(number, significant, precision) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of function heads as a replacement for conditionals?

defp rounded_or_significant(number, true, precision), do: make_significant(number, precision)
defp rounded_or_significant(number, false, precision) when precision <= 0 do
  number |> Float.round(precision) |> trunc |> Integer.to_string
end
defp rounded_or_significant(number, false, precision) do
  number |> Float.round(precision) |> :io_lib.format("~.#{precision}f", [number]) |> List.to_string
end

Also, now that I've implemented Number.Format.as_rounded, should this function use that?

case significant do
true ->
make_significant(number, precision)
false ->
number = Float.round(number, precision)

if precision > 0 do
:io_lib.format("~.#{precision}f", [number]) |> List.to_string
else
number |> trunc |> Integer.to_string
end
end
end

defp make_significant(number, precision) do
digits = (:math.log10(number) + 1) |> Float.floor |> trunc
multiplier = :math.pow(10, digits - precision)
extra_precision = precision - digits

result = Float.round(number / multiplier) * multiplier

if extra_precision > 0 do
:io_lib.format("~.#{extra_precision}f", [result]) |> List.to_string
else
result |> trunc |> Integer.to_string
end
end

defp strip_trailing_zeros(number, strip) do
if strip do
strip_trailing_zeros(number)
else
number
end
end

defp strip_trailing_zeros(number) do
if String.contains?(number, ".") do
case String.reverse(number) do
"0" <> number -> String.reverse(number) |> strip_trailing_zeros
"." <> number -> String.reverse(number)
number -> String.reverse(number)
end
else
number
end
end

defp format_as_human(binary, unit, format) when is_binary(binary) do
str = String.replace(format, "%n", binary, global: false)
String.replace(str, "%u", unit, global: false) |> String.strip
end

@doc ~S"""
Throwing version of `as_human`, raises if the input is not a valid number.
"""
def as_human!(number, opts \\ [])
def as_human!(number, opts) when is_binary(number) do
case Float.parse(number) do
{num, ""} -> as_human(num, opts)
_ -> raise(ArithmeticError, message: "bad argument in arithmetic expression")
end
end

def as_human!(number, opts) when is_number(number) do
as_human(number, opts)
end
end