Packages

Contex - a server-side charting library for Elixir.

Current section

Files

Jump to
contex lib chart scale time_scale.ex
Raw

lib/chart/scale/time_scale.ex

defmodule Contex.TimeScale do
alias __MODULE__
alias Timex.Format.DateTime.Formatters.Default, as: DateFormatter
alias Contex.Utils
# Approximate durations in ms for calculating ideal tick intervals
# Modelled from https://github.com/d3/d3-scale/blob/v2.2.2/src/time.js
@duration_sec 1000
@duration_min @duration_sec * 60
@duration_hour @duration_min * 60
@duration_day @duration_hour * 24
#@duration_week @duration_day * 7
@duration_month @duration_day * 30
@duration_year @duration_day * 365
#Tuple defines: 1&2 - actual time intervals to calculate tick offsets & 3, approximate time interval to determine if this is the best option
@default_tick_intervals [
{:second, 1, @duration_sec},
{:second, 5, @duration_sec * 5},
{:second, 15, @duration_sec * 15},
{:second, 30, @duration_sec * 30},
{:minute, 1, @duration_min},
{:minute, 5, @duration_min * 5},
{:minute, 15, @duration_min * 15},
{:minute, 30, @duration_min * 30},
{:hour, 1, @duration_hour },
{:hour, 3, @duration_hour * 3},
{:hour, 6, @duration_hour * 6},
{:hour, 12, @duration_hour * 12},
{:day, 1, @duration_day },
{:day, 2, @duration_day * 2},
{:day, 5, @duration_day * 5},
# {:week, 1, @duration_week }, #TODO: Need to work on tick_interval lookup function & related to make this work
{:day, 10, @duration_day * 10},
{:month, 1, @duration_month },
{:month, 3, @duration_month * 3},
{:year, 1, @duration_year}
]
defstruct [:domain, :nice_domain, :range,
:domain_to_range_fn, :range_to_domain_fn, :interval_count, :tick_interval,
:custom_tick_formatter, :display_format]
def new() do
%TimeScale{range: {0.0, 1.0}, interval_count: 10}
end
def interval_count(%TimeScale{} = 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(%TimeScale{} = scale, _), do: scale
def domain(%TimeScale{} = scale, min, 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 Utils.date_compare(min, max) do
:lt -> {min, max}
_ -> {max, min}
end
%{scale | domain: {d_min, d_max}}
|> nice
|> update_transform_funcs
end
def domain(%TimeScale{} = 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
# TODO: no type checks on the domain
defp nice(%TimeScale{domain: {min_d, max_d}, interval_count: interval_count} = scale)
when is_number(interval_count) and interval_count > 1
do
width = Timex.diff(max_d, min_d, :milliseconds)
unrounded_interval_size = width / (interval_count - 1)
tick_interval = lookup_tick_interval(unrounded_interval_size)
min_nice = round_down_to(min_d, tick_interval)
{max_nice, adjusted_interval_count} = calculate_end_interval(min_nice, max_d, tick_interval, interval_count)
display_format = guess_display_format(tick_interval)
%{scale | nice_domain: {min_nice, max_nice}, tick_interval: tick_interval, interval_count: adjusted_interval_count, display_format: display_format}
end
defp nice(%TimeScale{} = scale), do: scale
defp lookup_tick_interval(raw_interval) when is_number(raw_interval) do
result = Enum.find(@default_tick_intervals, fn {_,_,duration} -> duration >= raw_interval end)
case result do
nil -> Enum.take(@default_tick_intervals, -1)
v -> v
end
end
defp calculate_end_interval(start, target, {interval_type, interval_size, _}, max_steps) do
Enum.reduce_while(1..max_steps, {start, 0}, fn step, {_current_end, _index} ->
new_end = add_interval(start, interval_type, (step * interval_size))
if (Utils.date_compare(new_end, target) == :lt), do: {:cont, {new_end, step}}, else: {:halt, {new_end, step}}
end)
end
def add_interval(dt, :second, intervals), do: Timex.shift(dt, seconds: intervals)
def add_interval(dt, :minute, intervals), do: Timex.shift(dt, minutes: intervals)
def add_interval(dt, :hour, intervals), do: Timex.shift(dt, hours: intervals)
def add_interval(dt, :day, intervals), do: Timex.shift(dt, days: intervals)
def add_interval(dt, :month, intervals), do: Timex.shift(dt, months: intervals)
def add_interval(dt, :year, intervals), do: Timex.shift(dt, years: intervals)
#NOTE: Don't try this at home kiddies. Relies on internal representations of DateTime and NaiveDateTime
defp round_down_to(dt, {:second, n, _}), do: %{dt | microsecond: {0,0}, second: round_down_multiple(dt.second, n)}
defp round_down_to(dt, {:minute, n, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: round_down_multiple(dt.minute, n)}
defp round_down_to(dt, {:hour, n, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: 0, hour: round_down_multiple(dt.hour, n)}
defp round_down_to(dt, {:day, 1, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: 0, hour: 0}
defp round_down_to(dt, {:day, n, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: 0, hour: 0, day: round_down_multiple(dt.day, n) + 1}
defp round_down_to(dt, {:month, n, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: 0, hour: 0, day: 1, month: round_down_multiple(dt.month, n) + 1}
defp round_down_to(dt, {:year, 1, _}), do: %{dt | microsecond: {0,0}, second: 0, minute: 0, hour: 0, day: 1, month: 1}
defp guess_display_format({:second, _, _}), do: "{m}:{s}"
defp guess_display_format({:minute, _, _}), do: "{h24}:{m}:{s}"
defp guess_display_format({:hour, 1, _}), do: "{ISOtime}"
defp guess_display_format({:hour, _, _}), do: "{D} {Mshort} {h24}:{m}"
defp guess_display_format({:day, _, _}), do: "{ISOdate}"
defp guess_display_format({:month, _, _}), do: "{Mshort} {YYYY}"
defp guess_display_format({:year, _, _}), do: "{YYYY}"
def update_transform_funcs(%TimeScale{nice_domain: {min_d, max_d}, range: {min_r, max_r}} = scale)
when is_number(min_r) and is_number(max_r)
do
domain_width = Timex.diff(max_d, min_d, :microsecond)
domain_min = 0
range_width = max_r - min_r
domain_to_range_fn = case domain_width do
0 -> fn x -> x end
_ ->
fn domain_val ->
milliseconds_val = Timex.diff(domain_val, min_d, :microsecond)
ratio = (milliseconds_val - domain_min) / 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
Timex.add(min_d, Timex.Duration.from_microseconds(trunc(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(%TimeScale{} = 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
defp round_down_multiple(value, multiple), do: div(value, multiple) * multiple
defimpl Contex.Scale do
def domain_to_range_fn(%TimeScale{domain_to_range_fn: domain_to_range_fn}), do: domain_to_range_fn
def ticks_domain(%TimeScale{nice_domain: {min_d, _}, interval_count: interval_count, tick_interval: {interval_type, interval_size, _}})
when is_number(interval_count)
do
0..interval_count
|> Enum.map(fn i -> TimeScale.add_interval(min_d, interval_type, (i * interval_size)) end)
end
def ticks_domain(_), do: []
def ticks_range(%TimeScale{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(%TimeScale{domain_to_range_fn: transform_func}, range_val) when is_function(transform_func) do
transform_func.(range_val)
end
def get_range(%TimeScale{range: {min_r, max_r}}), do: {min_r, max_r}
def set_range(%TimeScale{} = scale, start, finish) when is_number(start) and is_number(finish) do
%{scale | range: {start, finish}}
|> TimeScale.update_transform_funcs
end
def set_range(%TimeScale{} = scale, {start, finish}) when is_number(start) and is_number(finish), do: set_range(scale, start, finish)
def get_formatted_tick(%TimeScale{display_format: display_format, custom_tick_formatter: custom_tick_formatter}, tick_val) do
format_tick_text(tick_val, display_format, 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, display_format, _) do
{:ok, result} = DateFormatter.format(tick, display_format)
result
end
end
end