Scales.Breaks (scales v0.1.0)

Copy Markdown View Source

Where a continuous axis's breaks go, given its limits: evenly spaced numbers, nice to read, that cover them (extended/2), round steps that cover them (pretty/2), or every multiple of a width (fullseq/3), moved by an offset (width/3), between numbers, dates, date-times or times of day; powers of a base and their multiples for a logarithmic axis (log/2), whole numbers for an axis of exponentials (exp/2), and whole seconds, minutes, hours, days or weeks for spans of time (timespan/2). The breaks may fall outside the limits; which to keep is the caller's.

Summary

Types

A step on a calendar or a clock, as Date.shift/2 writes one: units and their counts ([month: 1]).

A unit a span of time is counted in.

A value broken: a number, or a date, date-time or time of day.

Functions

Breaks for an axis of exponentials over the limits {low, high}, in either order, R's breaks_exp().

The breaks of the extended Wilkinson sequence that scores best over the limits {low, high}, low below high, in ascending order, R's breaks_extended(), n: breaks wanted.

Every multiple of size from the low limit rounded down to one to the high limit rounded up, R's fullseq(), the limits in either order.

Breaks for a logarithmic axis over the limits {low, high}, positive numbers in either order, R's breaks_log().

About n: intervals of round values covering the limits {low, high}, in either order, R's breaks_pretty(), its pretty().

Breaks for an axis of spans of time over the limits {low, high}, in either order, counted in unit:, in seconds, R's breaks_timespan().

Breaks width apart, R's breaks_width(): fullseq/3's sequence over the limits, each moved by offset:.

Types

step()

@type step() :: keyword(integer())

A step on a calendar or a clock, as Date.shift/2 writes one: units and their counts ([month: 1]).

timespan_unit()

@type timespan_unit() :: :second | :minute | :hour | :day | :week

A unit a span of time is counted in.

value()

@type value() :: number() | Date.t() | NaiveDateTime.t() | DateTime.t() | Time.t()

A value broken: a number, or a date, date-time or time of day.

Functions

exp(arg, options \\ [])

@spec exp({number(), number()}, keyword()) :: [float()]

Breaks for an axis of exponentials over the limits {low, high}, in either order, R's breaks_exp().

Each limit below 0 is taken as 0. Where the high limit reaches 3 and the limits are 3 or more apart, the breaks are the high limit rounded down and the whole numbers below it, n: less one of them at most, then 0, in that order, high to low, as R gives them; otherwise they are extended/2's breaks on the limits, which every option here reaches.

Options

  • :n - The number of breaks wanted, 2 or more, labeling::extended()'s m; the breaks found may number a few more or fewer. The default value is 5.

  • :nice_numbers - The steps a sequence may take, each times a power of ten, in order of preference, labeling::extended()'s Q. The default value is [1, 5, 2, 2.5, 4, 3].

  • :only_loose (boolean/0) - Whether the breaks must reach the limits, the first at or below the low one and the last at or above the high one, labeling::extended()'s only.loose. The default value is false.

  • :weights - The weights of simplicity, coverage, density and legibility, four numbers, the first three positive, labeling::extended()'s w. The default value is [0.25, 0.2, 0.5, 0.05].

Examples

iex> Scales.Breaks.exp({0.0, 100.0}, n: 4)
[100.0, 99.0, 98.0, 0.0]

iex> Scales.Breaks.exp({100.0, 102.0})
[100.0, 100.5, 101.0, 101.5, 102.0]

extended(limits, options \\ [])

@spec extended({float(), float()}, keyword()) :: [float()]

The breaks of the extended Wilkinson sequence that scores best over the limits {low, high}, low below high, in ascending order, R's breaks_extended(), n: breaks wanted.

It searches for the evenly spaced sequence that scores best on simplicity (a step from the nice numbers times a power of ten, early in their list, and zero among the breaks), coverage (the sequence spans the limits without overshooting them far), density (close to the number of breaks wanted) and legibility, weighted as R weights them. Limits narrower than 100 times the machine epsilon, or wider than the square root of the largest float, get the number wanted spaced evenly from end to end.

Options

  • :n - The number of breaks wanted, 2 or more, labeling::extended()'s m; the breaks found may number a few more or fewer. The default value is 5.

  • :nice_numbers - The steps a sequence may take, each times a power of ten, in order of preference, labeling::extended()'s Q. The default value is [1, 5, 2, 2.5, 4, 3].

  • :only_loose (boolean/0) - Whether the breaks must reach the limits, the first at or below the low one and the last at or above the high one, labeling::extended()'s only.loose. The default value is false.

  • :weights - The weights of simplicity, coverage, density and legibility, four numbers, the first three positive, labeling::extended()'s w. The default value is [0.25, 0.2, 0.5, 0.05].

Examples

iex> Scales.Breaks.extended({9.225, 35.075})
[10.0, 15.0, 20.0, 25.0, 30.0, 35.0]

iex> Scales.Breaks.extended({0.0, 10.0})
[0.0, 2.5, 5.0, 7.5, 10.0]

iex> Scales.Breaks.extended({0.0, 10.0}, nice_numbers: [1, 2, 1.5, 4, 3])
[0.0, 2.0, 4.0, 6.0, 8.0, 10.0]

iex> Scales.Breaks.extended({52.0, 335.0}, n: 2)
[0.0, 300.0]

fullseq(limits, size, options \\ [])

@spec fullseq({value(), value()}, number() | step(), keyword()) :: [value()]

Every multiple of size from the low limit rounded down to one to the high limit rounded up, R's fullseq(), the limits in either order.

On numbers size is a positive number; limits that are one number, as R's zero_range() judges them, give that number half a size either side. On dates, date-times and times it is a step of one unit, as Date.shift/2 writes one ([month: 1]), where R takes "1 month", and the sequence is Dates, date-times or Times laid as R lays them: from the low limit rounded down to the step's unit (a week to its Monday, a month to its 1st, seconds and minutes to the nearest multiple of the step since 1970-01-01 00:00 UTC, or since midnight for a time) to the high one rounded up, a date's units a day, a week, a month or a year, a time's a second, a minute or an hour, a date-time's any of the seven. A DateTime is broken on its zone's clock, read by time_zone_database:; months and years step on the calendar, the rest by elapsed time, as R's seq() steps them.

Options

  • :pad (boolean/0) - Whether a sequence of numbers gains a step before it and one after, R's pad; refused for dates, date-times and times. The default value is false.

  • :time_zone_database (atom/0) - The time zone database that reads a DateTime's zone, such as Tz.TimeZoneDatabase; Elixir's own, which knows UTC alone, when not given. The default value is Calendar.UTCOnlyTimeZoneDatabase.

Examples

iex> Scales.Breaks.fullseq({1.6, 7.0}, 2)
[0.0, 2.0, 4.0, 6.0, 8.0]

iex> Scales.Breaks.fullseq({1.6, 7.0}, 2, pad: true)
[-2.0, 0.0, 2.0, 4.0, 6.0, 8.0, 10.0]

iex> Scales.Breaks.fullseq({~D[2026-01-10], ~D[2026-04-20]}, month: 1)
[~D[2026-01-01], ~D[2026-02-01], ~D[2026-03-01], ~D[2026-04-01], ~D[2026-05-01]]

log(limits, options \\ [])

@spec log({number(), number()}, keyword()) :: [float()]

Breaks for a logarithmic axis over the limits {low, high}, positive numbers in either order, R's breaks_log().

It takes every power of base: from the low limit's logarithm rounded down to the high one's rounded up, or every second, third and so on, the widest step that leaves n: breaks less two inside the limits. Where every power leaves too few, it adds multiples of each power, one multiple at a time, each the whole number below the base that keeps the steps evenest on the logarithm (3 first for base 10), until enough fall inside, and keeps those inside with one more either side; and where even every whole multiple leaves too few, extended/2's breaks on the limits, n: wanted. A base of 2 or less adds no multiples: its powers are the breaks, however few fall inside. Limits whose logarithms lie between the same two whole numbers but round to one give that power alone.

Options

  • :n (pos_integer/0) - The number of breaks wanted, R's n: a step is enough when it leaves that many less two inside the limits. The default value is 5.

  • :base - The logarithm's base, a number above 1, R's base. The default value is 10.

Examples

iex> Scales.Breaks.log({1.0, 1.0e5})
[1.0, 100.0, 1.0e4]

iex> Scales.Breaks.log({2000.0, 9000.0})
[2000.0, 3000.0, 5000.0, 7000.0, 10000.0]

iex> Scales.Breaks.log({3.0, 5.0}, base: 2)
[2.0, 4.0, 8.0]

pretty(limits, options \\ [])

@spec pretty({value(), value()}, keyword()) :: [
  number()
  | Time.t()
  | {Date.t() | NaiveDateTime.t() | DateTime.t(), String.t()}
]

About n: intervals of round values covering the limits {low, high}, in either order, R's breaks_pretty(), its pretty().

On numbers each step is 1, 2, 5 or 10 times a power of ten: the limits' width over the number wanted, rounded to the nearest of those, and the breaks every multiple of it from the low limit rounded down to one to the high limit rounded up. On dates and date-times they are R's calendar and clock steps, from a second to a thousand years, the one that cuts the limits closest to n: times, a DateTime broken on its zone's clock, read by time_zone_database:, each break with its label, {break, label}, as R names its breaks: written in the format of the step, as Scales.Transform.format/4 writes them; on times of day the numbers' breaks on their seconds since midnight, as R breaks an hms, each a Time, wrapped past midnight. Limits that are one value, as R's zero_range() judges them, give the first alone, a date's or date-time's labelled in full, as R writes a break it has not named.

R's documentation describes its numbers' steps but for the rule that rounds the width over the number wanted, which it leaves out; this rounds it to the nearest nice number as Heckbert's nice numbers do (Graphics Gems, 1990), but up from R's own switches, measured from its output: above 1.4, 2.8 and 7 times its power of ten.

Options

  • :n (non_neg_integer/0) - The number of intervals wanted, R's n; the breaks found may cut the limits a few more or fewer times. The default value is 5.

  • :time_zone_database (atom/0) - The time zone database that reads a DateTime's zone, such as Tz.TimeZoneDatabase; Elixir's own, which knows UTC alone, when not given. The default value is Calendar.UTCOnlyTimeZoneDatabase.

Examples

iex> Scales.Breaks.pretty({1.0, 15.0})
[0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0]

iex> Scales.Breaks.pretty({1.0, 20.0}, n: 2)
[0.0, 10.0, 20.0]

iex> Scales.Breaks.pretty({~D[2020-01-01], ~D[2022-01-01]})
[
  {~D[2020-01-01], "2020-01"},
  {~D[2020-07-01], "2020-07"},
  {~D[2021-01-01], "2021-01"},
  {~D[2021-07-01], "2021-07"},
  {~D[2022-01-01], "2022-01"}
]

iex> Scales.Breaks.pretty({~T[00:00:00], ~T[01:00:00]})
[~T[00:00:00], ~T[00:16:40], ~T[00:33:20], ~T[00:50:00], ~T[01:06:40]]

timespan(arg, options \\ [])

@spec timespan({number(), number()}, keyword()) :: [float()]

Breaks for an axis of spans of time over the limits {low, high}, in either order, counted in unit:, in seconds, R's breaks_timespan().

The limits are measured in the unit that suits their span: seconds over two minutes or less, minutes over two hours or less, hours over two days or less, days over two weeks or less, else weeks; there they take extended/2's breaks with the nice numbers 1, 2, 1.5, 4 and 3, n: wanted, which are then measured in seconds, so they fall on whole seconds, minutes, hours, days or weeks. R gives them as a difftime of seconds; here they are numbers.

Options

  • :unit - The unit the limits are counted in, one of :second, :minute, :hour, :day and :week, R's unit; the breaks are seconds. The default value is :second.

  • :n - The number of breaks wanted, 2 or more, R's n. The default value is 5.

Examples

iex> Scales.Breaks.timespan({0.0, 100.0}, unit: :minute)
[0.0, 1200.0, 2400.0, 3600.0, 4800.0, 6000.0]

iex> Scales.Breaks.timespan({0.0, 100.0}, n: 3)
[0.0, 100.0]

width(limits, width, options \\ [])

@spec width({value(), value()}, number() | step(), keyword()) :: [value()]

Breaks width apart, R's breaks_width(): fullseq/3's sequence over the limits, each moved by offset:.

On numbers the offset is a number added to each. On dates, date-times and times it is a list of steps ([month: 1, day: 8]), each moving every break in turn, as R moves them by each of a vector of offsets: seconds, minutes, hours, days and weeks by elapsed time, months and years on the calendar, a day past the end of a month carried into the next, as R carries it, so 31 January and a month is 3 March. The breaks stay in the order the offset leaves them, unsorted where it moves two onto one day, as R's are. A time of day wraps past midnight, where R's hms runs on past 24 hours.

Options

  • :offset - How far each break is moved: a number for numbers, 0 when not given; a list of steps for dates, date-times and times, each a unit and a whole count, negative to move back ([month: 1, day: 8]).

  • :time_zone_database (atom/0) - The time zone database that reads a DateTime's zone, such as Tz.TimeZoneDatabase; Elixir's own, which knows UTC alone, when not given. The default value is Calendar.UTCOnlyTimeZoneDatabase.

Examples

iex> Scales.Breaks.width({1.6, 7.0}, 1, offset: 0.5)
[1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]

iex> Scales.Breaks.width({~D[2026-01-10], ~D[2026-04-20]}, [month: 1], offset: [day: 8])
[~D[2026-01-09], ~D[2026-02-09], ~D[2026-03-09], ~D[2026-04-09], ~D[2026-05-09]]