defmodule Contex.ContinuousLogScale do
@moduledoc """
A logarithmic scale to map continuous numeric data to a plotting coordinate system.
This works like `Contex.ContinuousLinearScale`, and the
settings are given as keywords.
ContinuousLogScale.new(
domain: {0, 100},
tick_positions: [0, 5, 10, 15, 30, 60, 120, 240, 480, 960],
log_base: :base_10,
negative_numbers: :mask,
linear_range: 1
)
**Logarithm**
- `log_base` is the logarithm base. Defaults to 2. Can be
set to `:base_2`, `:base_e` or `:base_10`.
- `negative_numbers` controls how negative numbers are represented.
It can be:
* `:mask`: always return 0
* `:clip`: always returns 0
* `:sym`: logarithms are drawn symmetrically, that is, the log of a
number *n* when n < 0 is -log(abs(n))
- `linear_range` is a range -if any- where results are not logarithmical
**Data domain**
Unfortunately, a domain must be given for all custom scales.
To make your life easier, you can either:
- `domain: {0, 27}` will set an explicit domain, or
- `dataset` and `axis` let you specify a Dataset and one or a list of axes,
and the domain will be computed out of them all.
**Ticks**
- `interval_count` divides the interval in `n` linear slices, or
- `tick_positions` can receive a list of explicit possible
ticks, that will be displayed ony if they are within the domain
area.
- `custom_tick_formatter` is a function to be applied to the
ticks.
"""
alias __MODULE__
alias Contex.ScaleUtils
alias Contex.Dataset
defstruct [
:domain,
:range,
:log_base_fn,
:negative_numbers,
:linear_range,
:custom_tick_formatter,
:tick_positions,
:interval_count,
# These are compouted automagically
:nice_domain,
:display_decimals
]
@type t() :: %__MODULE__{}
@doc """
Creates a new scale with defaults.
"""
@spec new :: Contex.ContinuousLogScale.t()
def new(options \\ []) do
dom =
get_domain(
Keyword.get(options, :domain, :notfound),
Keyword.get(options, :dataset, :notfound),
Keyword.get(options, :axis, :notfound)
)
|> ScaleUtils.validate_range(":domain")
rng =
Keyword.get(options, :range, nil)
|> ScaleUtils.validate_range_nil(":range")
ic = Keyword.get(options, :interval_count, 10)
is = Keyword.get(options, :tick_positions, nil)
lb =
Keyword.get(options, :log_base, :base_2)
|> ScaleUtils.validate_option(":log_base", [:base_2, :base_e, :base_10])
neg_num =
Keyword.get(options, :negative_numbers, :clip)
|> ScaleUtils.validate_option(":negative_numbers", [:clip, :mask, :sym])
lin_rng = Keyword.get(options, :linear_range, nil)
ctf = Keyword.get(options, :custom_tick_formatter, nil)
log_base_fn =
case lb do
:base_2 -> &:math.log2/1
:base_e -> &:math.log/1
:base_10 -> &:math.log10/1
end
%ContinuousLogScale{
domain: dom,
nice_domain: nil,
range: rng,
tick_positions: is,
interval_count: ic,
display_decimals: nil,
custom_tick_formatter: ctf,
log_base_fn: log_base_fn,
negative_numbers: neg_num,
linear_range: lin_rng
}
|> nice()
end
@doc """
Fixes inconsistencies and scales.
"""
@spec nice(Contex.ContinuousLogScale.t()) :: Contex.ContinuousLogScale.t()
def nice(
%ContinuousLogScale{
domain: {min_d, max_d},
interval_count: interval_count,
tick_positions: tick_positions
} = c
) do
%{
nice_domain: nice_domain,
ticks: computed_ticks,
display_decimals: display_decimals
} =
ScaleUtils.compute_nice_settings(
min_d,
max_d,
tick_positions,
interval_count
)
%{
c
| nice_domain: nice_domain,
tick_positions: computed_ticks,
display_decimals: display_decimals
}
end
@spec get_domain(:notfound | {any, any}, any, any) :: {number(), number()}
@doc """
Computes the correct domain {a, b}.
- If it is explicitly passed, we use it.
- If there is a dataset and a column or a list of columns, we use that
- If all else fails, we use {0, 1}
"""
def get_domain(:notfound, %Dataset{} = requested_dataset, requested_columns)
when is_list(requested_columns) do
all_ranges =
requested_columns
|> Enum.map(fn c -> Dataset.column_extents(requested_dataset, c) end)
minimum =
all_ranges
|> Enum.map(fn {min, _} -> min end)
|> Enum.min()
maximum =
all_ranges
|> Enum.map(fn {_, max} -> max end)
|> Enum.max()
{minimum, maximum}
end
def get_domain(:notfound, %Dataset{} = requested_dataset, requested_column),
do: get_domain(:notfound, requested_dataset, [requested_column])
def get_domain({_a, _b} = requested_domain, _requested_dataset, _requested_column),
do: requested_domain
def get_domain(_, _, _), do: {0, 1}
@doc """
Translates a value into its logarithm,
given the mode and an optional linear part.
"""
@spec log_value(number(), function(), :clip | :mask | :sym, float()) :: any
def log_value(v, fn_exp, mode, lin) when is_number(v) or is_float(lin) or is_nil(lin) do
is_lin_area =
case lin do
nil -> false
_ -> abs(v) < lin
end
# IO.puts("#{inspect({v, mode, is_lin_area, v > 0})}")
case {mode, is_lin_area, v > 0} do
{:mask, _, false} ->
0
{:mask, true, true} ->
v
{:mask, false, true} ->
fn_exp.(v)
{:clip, _, false} ->
0
{:clip, true, true} ->
v
{:clip, false, true} ->
fn_exp.(v)
{:sym, true, _} ->
v
{:sym, false, false} ->
if v < 0 do
0 - fn_exp.(-v)
else
0
end
{:sym, false, true} ->
fn_exp.(v)
end
end
@spec get_domain_to_range_function(Contex.ContinuousLogScale.t()) :: (number -> float)
def get_domain_to_range_function(
%ContinuousLogScale{
domain: {min_d, max_d},
range: {min_r, max_r},
log_base_fn: log_base_fn,
negative_numbers: neg_num,
linear_range: lin_rng
} = _scale
) do
log_fn = fn v -> log_value(v, log_base_fn, neg_num, lin_rng) end
min_log_d = log_fn.(min_d)
max_log_d = log_fn.(max_d)
width_d = max_log_d - min_log_d
width_r = max_r - min_r
fn x ->
log_x = log_fn.(x)
v = ScaleUtils.rescale_value(log_x, min_log_d, width_d, min_r, width_r)
# IO.puts("Domain: #{x} -> #{log_x} -> #{v}")
v
end
end
# ===============================================================
# Implementation of Contex.Scale
defimpl Contex.Scale do
@spec domain_to_range_fn(Contex.ContinuousLogScale.t()) :: (number -> float)
def domain_to_range_fn(%ContinuousLogScale{} = scale),
do: ContinuousLogScale.get_domain_to_range_function(scale)
@spec ticks_domain(Contex.ContinuousLogScale.t()) :: list(number)
def ticks_domain(%ContinuousLogScale{
tick_positions: tick_positions
}) do
tick_positions
end
def ticks_domain(_), do: []
@spec ticks_range(Contex.ContinuousLogScale.t()) :: list(number)
def ticks_range(%ContinuousLogScale{} = scale) do
transform_func = ContinuousLogScale.get_domain_to_range_function(scale)
ticks_domain(scale)
|> Enum.map(transform_func)
end
@spec domain_to_range(Contex.ContinuousLogScale.t(), number) :: float
def domain_to_range(%ContinuousLogScale{} = scale, range_val) do
transform_func = ContinuousLogScale.get_domain_to_range_function(scale)
transform_func.(range_val)
end
@spec get_range(Contex.ContinuousLogScale.t()) :: {number, number}
def get_range(%ContinuousLogScale{range: {min_r, max_r}}), do: {min_r, max_r}
@spec set_range(Contex.ContinuousLogScale.t(), number, number) ::
Contex.ContinuousLogScale.t()
def set_range(%ContinuousLogScale{} = scale, start, finish)
when is_number(start) and is_number(finish) do
%{scale | range: {start, finish}}
end
@spec set_range(Contex.ContinuousLogScale.t(), {number, number}) ::
Contex.ContinuousLogScale.t()
def set_range(%ContinuousLogScale{} = scale, {start, finish})
when is_number(start) and is_number(finish),
do: set_range(scale, start, finish)
@spec get_formatted_tick(Contex.ContinuousLogScale.t(), any) :: binary
def get_formatted_tick(
%ContinuousLogScale{
display_decimals: display_decimals,
custom_tick_formatter: custom_tick_formatter
},
tick_val
) do
ScaleUtils.format_tick_text(tick_val, display_decimals, custom_tick_formatter)
end
end
end