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