Packages

Contex - a server-side charting library for Elixir.

Current section

Files

Jump to
contex lib chart scale continuous_scale.ex
Raw

lib/chart/scale/continuous_scale.ex

defmodule Contex.ContinuousScale do
alias __MODULE__
alias Contex.Utils
defstruct [:domain, :nice_domain, :range,
:domain_to_range_fn, :range_to_domain_fn, :interval_count, :interval_size,
:display_decimals, :custom_tick_formatter]
def new_linear() do
%ContinuousScale{range: {0.0, 1.0}, interval_count: 10, display_decimals: nil}
end
def interval_count(%ContinuousScale{} = scale, interval_count) when is_integer(interval_count) and interval_count > 1 do
%{scale | interval_count: interval_count}
|> nice
|> update_transform_funcs
end
def interval_count(%ContinuousScale{} = scale, _), do: scale
def domain(%ContinuousScale{} = scale, min, max) when is_number(min) and is_number(max) do
# We can be flexible with the range start > end, but the domain needs to start from the min
{d_min, d_max} = case min < max do
true -> {min, max}
_ -> {max, min}
end
%{scale | domain: {d_min, d_max}}
|> nice
|> update_transform_funcs
end
def domain(%ContinuousScale{} = scale, data) when is_list(data) do
{min, max} = extents(data)
domain(scale, min, max)
end
# NOTE: interval count will likely get adjusted down here to keep things looking nice
defp nice(%ContinuousScale{domain: {min_d, max_d}, interval_count: interval_count} = scale)
when is_number(min_d) and is_number(max_d) and is_number(interval_count) and interval_count > 1
do
width = max_d - min_d
width = if width == 0.0, do: 1.0, else: width
unrounded_interval_size = width / (interval_count - 1)
order_of_magnitude = :math.ceil(:math.log10(unrounded_interval_size) - 1)
power_of_ten = :math.pow(10, order_of_magnitude)
rounded_interval_size = lookup_axis_interval(unrounded_interval_size / power_of_ten) * power_of_ten
min_nice = rounded_interval_size * Float.floor(min_d / rounded_interval_size)
max_nice = rounded_interval_size * Float.ceil(max_d / rounded_interval_size)
adjusted_interval_count = round(1.0001 * (max_nice - min_nice) / rounded_interval_size)
display_decimals = guess_display_decimals(order_of_magnitude)
%{scale | nice_domain: {min_nice, max_nice}, interval_size: rounded_interval_size, interval_count: adjusted_interval_count, display_decimals: display_decimals}
end
defp nice(%ContinuousScale{} = scale), do: scale
@axis_interval_breaks [0.1, 0.2, 0.25, 0.4, 0.5, 0.75, 1.0, 2.0, 2.5, 4.0, 5.0, 7.5, 10.0]
defp lookup_axis_interval(raw_interval) when is_float(raw_interval) do
Enum.find(@axis_interval_breaks, fn x -> x >= raw_interval end)
end
defp guess_display_decimals(power_of_ten) when power_of_ten > 0 do 0 end
defp guess_display_decimals(power_of_ten) do 1 + (-1 * round(power_of_ten)) end
def update_transform_funcs(%ContinuousScale{nice_domain: {min_d, max_d}, range: {min_r, max_r}} = scale)
when is_number(min_d) and is_number(max_d) and is_number(min_r) and is_number(max_r)
do
domain_width = max_d - min_d
range_width = max_r - min_r
domain_to_range_fn = case domain_width do
0 -> fn x -> x end
_ ->
fn domain_val ->
ratio = (domain_val - min_d) / domain_width
min_r + (ratio * range_width)
end
end
range_to_domain_fn = case range_width do
0 -> fn x -> x end
_ ->
fn range_val ->
ratio = (range_val - min_r) / range_width
min_d + (ratio * domain_width)
end
end
%{scale | domain_to_range_fn: domain_to_range_fn, range_to_domain_fn: range_to_domain_fn}
end
def update_transform_funcs(%ContinuousScale{} = scale), do: scale
def extents(data) do
Enum.reduce(data, {nil, nil}, fn x, {min, max} -> {Utils.safe_min(x, min), Utils.safe_max(x, max)} end)
end
defimpl Contex.Scale do
def domain_to_range_fn(%ContinuousScale{domain_to_range_fn: domain_to_range_fn}), do: domain_to_range_fn
def ticks_domain(%ContinuousScale{nice_domain: {min_d, _}, interval_count: interval_count, interval_size: interval_size})
when is_number(min_d) and is_number(interval_count) and is_number(interval_size)
do
0..interval_count
|> Enum.map(fn i -> min_d + (i * interval_size) end)
end
def ticks_domain(_), do: []
def ticks_range(%ContinuousScale{domain_to_range_fn: transform_func} = scale) when is_function(transform_func) do
ticks_domain(scale)
|> Enum.map(transform_func)
end
def domain_to_range(%ContinuousScale{domain_to_range_fn: transform_func}, range_val) when is_function(transform_func) do
transform_func.(range_val)
end
def get_range(%ContinuousScale{range: {min_r, max_r}}), do: {min_r, max_r}
def set_range(%ContinuousScale{} = scale, start, finish) when is_number(start) and is_number(finish) do
%{scale | range: {start, finish}}
|> ContinuousScale.update_transform_funcs
end
def set_range(%ContinuousScale{} = scale, {start, finish}) when is_number(start) and is_number(finish), do: set_range(scale, start, finish)
def get_formatted_tick(%ContinuousScale{display_decimals: display_decimals, custom_tick_formatter: custom_tick_formatter}, tick_val) do
format_tick_text(tick_val, display_decimals, custom_tick_formatter)
end
defp format_tick_text(tick, _, custom_tick_formatter) when is_function(custom_tick_formatter), do: custom_tick_formatter.(tick)
defp format_tick_text(tick, _, _) when is_integer(tick), do: tick
defp format_tick_text(tick, display_decimals, _) when display_decimals > 0 do
:erlang.float_to_binary(tick, [decimals: display_decimals])
end
defp format_tick_text(tick, _, _), do: :erlang.float_to_binary(tick, [:compact, decimals: 0])
end
end