defmodule Contex.TimeScale do
@moduledoc """
A time scale to map date and time data to a plotting coordinate system.
Almost identical `Contex.ContinuousLinearScale` in terms of concepts and
usage, except it applies to `DateTime` and `NaiveDateTime` domain data
types.
`TimeScale` handles the complexities of calculating nice tick intervals etc
for almost any time range between a few seconds and a few years.
"""
alias __MODULE__
alias Contex.Utils
@type datetimes() :: NaiveDateTime.t() | DateTime.t()
# 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 [
{:seconds, 1, @duration_sec},
{:seconds, 5, @duration_sec * 5},
{:seconds, 15, @duration_sec * 15},
{:seconds, 30, @duration_sec * 30},
{:minutes, 1, @duration_min},
{:minutes, 5, @duration_min * 5},
{:minutes, 15, @duration_min * 15},
{:minutes, 30, @duration_min * 30},
{:hours, 1, @duration_hour},
{:hours, 3, @duration_hour * 3},
{:hours, 6, @duration_hour * 6},
{:hours, 12, @duration_hour * 12},
{:days, 1, @duration_day},
{:days, 2, @duration_day * 2},
{:days, 5, @duration_day * 5},
# {:week, 1, @duration_week }, #TODO: Need to work on tick_interval lookup function & related to make this work
{:days, 10, @duration_day * 10},
{:months, 1, @duration_month},
{:months, 3, @duration_month * 3},
{:years, 1, @duration_year}
]
defstruct [
:domain,
:nice_domain,
:range,
:interval_count,
:tick_interval,
:custom_tick_formatter,
:display_format
]
@type t() :: %__MODULE__{}
@doc """
Creates a new TimeScale struct with basic defaults set
"""
@spec new :: Contex.TimeScale.t()
def new() do
%TimeScale{range: {0.0, 1.0}, interval_count: 11}
end
@doc """
Specifies the number of intervals the scale should display.
Default is 10.
"""
@spec interval_count(Contex.TimeScale.t(), integer()) :: Contex.TimeScale.t()
def interval_count(%TimeScale{} = scale, interval_count)
when is_integer(interval_count) and interval_count > 1 do
scale
|> struct(interval_count: interval_count)
|> nice()
end
def interval_count(%TimeScale{} = scale, _), do: scale
@doc """
Define the data domain for the scale
"""
@spec domain(Contex.TimeScale.t(), datetimes(), datetimes()) :: Contex.TimeScale.t()
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
|> struct(domain: {d_min, d_max})
|> nice()
end
@doc """
Define the data domain for the scale from a list of data.
Extents will be calculated by the scale.
"""
@spec domain(Contex.TimeScale.t(), list(datetimes())) :: Contex.TimeScale.t()
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 = Utils.date_diff(max_d, min_d, :millisecond)
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
default = List.last(@default_tick_intervals)
Enum.find(@default_tick_intervals, default, &(elem(&1, 2) >= raw_interval))
end
defp calculate_end_interval(start, target, tick_interval, max_steps) do
Enum.reduce_while(1..max_steps, {start, 0}, fn step, {_current_end, _index} ->
new_end = add_interval(start, tick_interval, step)
if Utils.date_compare(new_end, target) == :lt,
do: {:cont, {new_end, step}},
else: {:halt, {new_end, step}}
end)
end
@doc false
def add_interval(dt, {:seconds, _, duration_msec}, count),
do: Utils.date_add(dt, duration_msec * count, :millisecond)
def add_interval(dt, {:minutes, _, duration_msec}, count),
do: Utils.date_add(dt, duration_msec * count, :millisecond)
def add_interval(dt, {:hours, _, duration_msec}, count),
do: Utils.date_add(dt, duration_msec * count, :millisecond)
def add_interval(dt, {:days, _, duration_msec}, count),
do: Utils.date_add(dt, duration_msec * count, :millisecond)
def add_interval(dt, {:months, interval_size, _}, count),
do: Utils.date_add(dt, interval_size * count, :months)
def add_interval(dt, {:years, interval_size, _}, count),
do: Utils.date_add(dt, interval_size * count, :years)
# NOTE: Don't try this at home kiddies. Relies on internal representations of DateTime and NaiveDateTime
defp round_down_to(dt, {:seconds, n, _}),
do: %{dt | microsecond: {0, 0}, second: round_down_multiple(dt.second, n)}
defp round_down_to(dt, {:minutes, n, _}),
do: %{dt | microsecond: {0, 0}, second: 0, minute: round_down_multiple(dt.minute, n)}
defp round_down_to(dt, {:hours, n, _}),
do: %{dt | microsecond: {0, 0}, second: 0, minute: 0, hour: round_down_multiple(dt.hour, n)}
defp round_down_to(dt, {:days, 1, _}),
do: %{dt | microsecond: {0, 0}, second: 0, minute: 0, hour: 0}
defp round_down_to(dt, {:days, n, _}),
do: %{
dt
| microsecond: {0, 0},
second: 0,
minute: 0,
hour: 0,
day: round_down_multiple(dt.day, n) |> max(1)
}
defp round_down_to(dt, {:months, 1, _}),
do: %{dt | microsecond: {0, 0}, second: 0, minute: 0, hour: 0, day: 1}
defp round_down_to(dt, {:months, n, _}), do: round_down_month(dt, n)
defp round_down_to(dt, {:years, 1, _}),
do: %{dt | microsecond: {0, 0}, second: 0, minute: 0, hour: 0, day: 1, month: 1}
defp round_down_month(dt, n) do
month = round_down_multiple(dt.month, n)
year = dt.year
{month, year} =
case month > 0 do
true -> {month, year}
_ -> {month + 12, year - 1}
end
day = :calendar.last_day_of_the_month(year, month)
%{dt | microsecond: {0, 0}, second: 0, minute: 0, hour: 0, day: day, month: month, year: year}
end
defp guess_display_format({:seconds, _, _}), do: "%M:%S"
defp guess_display_format({:minutes, _, _}), do: "%H:%M:%S"
defp guess_display_format({:hours, 1, _}), do: "%H:%M:%S"
defp guess_display_format({:hours, _, _}), do: "%d %b %H:%M"
defp guess_display_format({:days, _, _}), do: "%d %b"
defp guess_display_format({:months, _, _}), do: "%b %Y"
defp guess_display_format({:years, _, _}), do: "%Y"
@doc false
def get_domain_to_range_function(%TimeScale{nice_domain: {min_d, max_d}, range: {min_r, max_r}})
when is_number(min_r) and is_number(max_r) do
domain_width = Utils.date_diff(max_d, min_d, :microsecond)
domain_min = 0
range_width = max_r - min_r
case domain_width do
0 ->
fn x -> x end
_ ->
fn domain_val ->
case domain_val do
nil ->
nil
_ ->
milliseconds_val = Utils.date_diff(domain_val, min_d, :microsecond)
ratio = (milliseconds_val - domain_min) / domain_width
min_r + ratio * range_width
end
end
end
end
def get_domain_to_range_function(_), do: fn x -> x end
@doc false
def get_range_to_domain_function(%TimeScale{nice_domain: {min_d, max_d}, range: {min_r, max_r}})
when is_number(min_r) and is_number(max_r) do
domain_width = Utils.date_diff(max_d, min_d, :microsecond)
range_width = max_r - min_r
case range_width do
0 ->
fn x -> x end
_ ->
fn range_val ->
ratio = (range_val - min_r) / range_width
Utils.date_add(min_d, trunc(ratio * domain_width), :microsecond)
end
end
end
def get_range_to_domain_function(_), do: fn x -> x end
defp 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{} = scale),
do: TimeScale.get_domain_to_range_function(scale)
def ticks_domain(%TimeScale{
nice_domain: {min_d, _},
interval_count: interval_count,
tick_interval: tick_interval
})
when is_number(interval_count) do
0..interval_count
|> Enum.map(fn i -> TimeScale.add_interval(min_d, tick_interval, i) end)
end
def ticks_domain(_), do: []
def ticks_range(%TimeScale{} = scale) do
transform_func = TimeScale.get_domain_to_range_function(scale)
ticks_domain(scale)
|> Enum.map(transform_func)
end
def domain_to_range(%TimeScale{} = scale, range_val) do
transform_func = TimeScale.get_domain_to_range_function(scale)
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}}
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: NimbleStrftime.format(tick, display_format)
end
end