///|
/// Calendar units supported by the explicit date locators.
pub(all) enum LocatorKind {
Automatic
Days
BusinessDays
Years
Months
Weekdays
NthWeekdays
Recurrence
Hours
Minutes
Seconds
Microseconds
}
///|
/// A calendar locator for UTC numeric dates, capturing its display timezone.
pub struct DateLocator {
kind : LocatorKind
interval : Int32
selector : Int32
secondary : Int32
selector_enabled : Bool
offset_minutes : Int32
priv timezone : DateTimeZone?
priv selection : Array[Int32]?
priv secondary_selection : Array[Int32]?
// Calendar-day keys excluded by a BusinessDays locator. The keys are
// Gregorian day counts, so an explicit holiday remains a calendar label
// when the locator is copied to another fixed UTC offset.
priv holidays : Array[Int32]
priv rule : DateRule?
priv automatic : AutoDateLocator?
priv wrapper_locator : RRuleLocator?
priv microsecond_object : MicrosecondLocator?
}
///|
/// Locate calendar days at a fixed number of day intervals.
pub fn date_locator(
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
day_locator(interval~, tz?, timezone?)
}
///|
/// Locate business days using a configurable weekday mask and explicit
/// holiday dates.
///
/// `weekdays` uses Monday=0 through Sunday=6 and defaults to Monday through
/// Friday. `holidays` is interpreted as a list of Gregorian calendar dates;
/// the time and fixed offset on each value are ignored. `interval` advances
/// calendar days before applying the weekday and holiday filters, matching
/// the interval behavior of `weekday_locator`.
///
/// ```mbt check
/// test {
/// let locator = @date.business_day_locator(holidays=[@date.datetime(2024, 1, 1)])
/// let ticks = locator.tick_values(
/// @date.datetime(2023, 12, 29).to_ordinal(),
/// @date.datetime(2024, 1, 5).to_ordinal(),
/// )
/// assert_eq(@date.format_date_ticks(ticks, format="%Y-%m-%d"), [
/// "2023-12-29", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05",
/// ])
/// }
/// ```
pub fn business_day_locator(
weekdays? : Array[Int32],
holidays? : Array[DateTime],
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = {
let selection = match weekdays {
Some(values) =>
locator_selection(None, Some(values), None, None, weekday_selector)
None => Some([0, 1, 2, 3, 4])
}
let holiday_days = business_holiday_days(holidays)
{
kind: BusinessDays,
interval: positive_interval(interval),
selector: match selection {
Some([first, ..]) => first
_ => 0
},
secondary: 0,
selector_enabled: true,
offset_minutes: 0,
timezone: None,
selection,
secondary_selection: None,
holidays: holiday_days,
rule: None,
automatic: None,
wrapper_locator: None,
microsecond_object: None,
}
}
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate a given month and day every `base` years, including aligned years
/// outside the visible range. Invalid dates in non-leap years are skipped.
pub fn year_locator(
base~ : Int32 = 1,
month~ : Int32 = 1,
day~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Years,
positive_interval(base),
Some([month_selector(month)]),
day_selector(day),
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate months, optionally filtering by `bymonth`, with day 1 as the default.
/// `bymonthday=-1` selects each month's last day. Impossible dates are skipped.
/// The interval counts calendar months before applying the filter.
///
/// ```mbt check
/// test {
/// let locator = @date.month_locator(bymonth=[2, 4], bymonthday=-1)
/// let ticks = locator.tick_values(
/// @date.datetime(2024, 1, 1).to_ordinal(),
/// @date.datetime(2024, 5, 1).to_ordinal(),
/// )
/// assert_eq(
/// ticks.map(value => @date.format_date(@date.from_ordinal(value), "%Y-%m-%d")),
/// ["2024-02-29", "2024-04-30"],
/// )
/// }
/// ```
pub fn month_locator(
interval~ : Int32 = 1,
bymonth? : Array[Int32],
bymonthday~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Months,
positive_interval(interval),
locator_selection(None, bymonth, None, None, month_selector),
monthday_selector(bymonthday),
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate one month at a fixed number of calendar month intervals.
pub fn month_locator_on(
month : Int32,
interval~ : Int32 = 1,
bymonthday~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
month_locator(interval~, bymonth=[month], bymonthday~, tz?, timezone?)
}
///|
/// Locate weekdays using Monday=0 through Sunday=6; the default is Tuesday.
/// Use either `weekday` or a nonempty `byweekday` array.
/// As in Matplotlib, `interval` counts days before filtering by weekday.
///
/// ```mbt check
/// test {
/// let locator = @date.weekday_locator(byweekday=[0, 2])
/// let ticks = locator.tick_values(
/// @date.datetime(2024, 1, 1).to_ordinal(),
/// @date.datetime(2024, 1, 7).to_ordinal(),
/// )
/// assert_eq(ticks.map(value => @date.from_ordinal(value).day()), [1, 3])
/// }
/// ```
pub fn weekday_locator(
weekday? : Int32,
interval~ : Int32 = 1,
byweekday? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Weekdays,
positive_interval(interval),
locator_selection(weekday, byweekday, None, Some(1), weekday_selector),
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate the nth occurrence of a weekday in each selected calendar month.
/// `ordinal` is in `-5..-1` or `1..5`; negative values count back from the
/// month's end. The interval counts months before applying `bymonth`.
/// Use `byweekday` to select more than one weekday; the emitted dates remain
/// in chronological order within each month.
///
/// ```mbt check
/// test {
/// let locator = @date.nth_weekday_locator(weekday=0, ordinal=2)
/// let ticks = locator.tick_values(
/// @date.datetime(2024, 1, 1).to_ordinal(),
/// @date.datetime(2024, 4, 30).to_ordinal(),
/// )
/// assert_eq(
/// ticks.map(value => @date.format_date(@date.from_ordinal(value), "%Y-%m-%d")),
/// ["2024-01-08", "2024-02-12", "2024-03-11", "2024-04-08"],
/// )
/// }
/// ```
pub fn nth_weekday_locator(
weekday? : Int32,
ordinal~ : Int32 = 1,
interval~ : Int32 = 1,
bymonth? : Array[Int32],
byweekday? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = {
if ordinal == 0 || ordinal < -5 || ordinal > 5 {
raise DateError::InvalidDate(
message="weekday ordinal must be in -5..-1 or 1..5",
)
}
let weekdays = locator_selection(
weekday,
byweekday,
None,
Some(1),
weekday_selector,
)
let months = locator_selection(None, bymonth, None, None, month_selector)
{
kind: NthWeekdays,
interval: positive_interval(interval),
selector: match weekdays {
Some([first, ..]) => first
_ => 1
},
secondary: ordinal,
selector_enabled: weekdays is Some(_),
offset_minutes: 0,
timezone: None,
selection: months,
secondary_selection: weekdays,
holidays: [],
rule: None,
automatic: None,
wrapper_locator: None,
microsecond_object: None,
}
}
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate days, optionally using `day` or a nonempty `bymonthday` array.
/// Negative selectors count from the end of the month (-1 is the last day).
/// The legacy scalar `day=0` selects all days. The interval counts days before
/// filtering, rather than counting occurrences of the selected month days.
///
/// ```mbt check
/// test {
/// let locator = @date.day_locator(bymonthday=[1, -1])
/// let ticks = locator.tick_values(
/// @date.datetime(2024, 2, 1).to_ordinal(),
/// @date.datetime(2024, 2, 29).to_ordinal(),
/// )
/// assert_eq(ticks.map(value => @date.from_ordinal(value).day()), [1, 29])
/// }
/// ```
pub fn day_locator(
day? : Int32,
interval~ : Int32 = 1,
bymonthday? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Days,
positive_interval(interval),
locator_selection(day, bymonthday, Some(0), None, monthday_selector),
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate hours using `hour` or a nonempty `byhour` array (0..23).
/// Omission or the legacy scalar -1 selects all hours; 0 selects midnight.
/// The interval counts hours before filtering.
///
/// ```mbt check
/// test {
/// let locator = @date.hour_locator(byhour=[0, 12])
/// assert_eq(locator.tick_values(0.0, 1.0), [0.0, 0.5, 1.0])
/// }
/// ```
pub fn hour_locator(
hour? : Int32,
interval~ : Int32 = 1,
byhour? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Hours,
positive_interval(interval),
locator_selection(hour, byhour, Some(-1), None, hour_selector),
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate one explicit hour of each day, including midnight when `hour` is 0.
pub fn hour_locator_at(
hour : Int32,
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
hour_locator(hour=hour_selector(hour), interval~, tz?, timezone?)
}
///|
/// Locate minutes using `minute` or a nonempty `byminute` array (0..59).
/// Omission or the legacy scalar -1 selects all minutes; 0 selects minute 0.
/// The interval counts minutes before filtering.
///
/// ```mbt check
/// test {
/// let locator = @date.minute_locator(byminute=[0, 30])
/// let ticks = locator.tick_values(0.0, 1.0 / 24.0)
/// assert_eq(ticks.map(value => @date.from_ordinal(value).minute()), [0, 30, 0])
/// }
/// ```
pub fn minute_locator(
minute? : Int32,
interval~ : Int32 = 1,
byminute? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Minutes,
positive_interval(interval),
locator_selection(
minute,
byminute,
Some(-1),
None,
minute_or_second_selector,
),
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate one explicit minute of each hour, including minute 0.
pub fn minute_locator_at(
minute : Int32,
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
minute_locator(
minute=minute_or_second_selector(minute),
interval~,
tz?,
timezone?,
)
}
///|
/// Locate seconds using `second` or a nonempty `bysecond` array (0..59).
/// Omission or the legacy scalar -1 selects all seconds; 0 selects second 0.
/// The interval counts seconds before filtering.
///
/// ```mbt check
/// test {
/// let locator = @date.second_locator(bysecond=[0, 30])
/// let ticks = locator.tick_values(0.0, 1.0 / 1440.0)
/// assert_eq(ticks.map(value => @date.from_ordinal(value).second()), [0, 30, 0])
/// }
/// ```
pub fn second_locator(
second? : Int32,
interval~ : Int32 = 1,
bysecond? : Array[Int32],
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Seconds,
positive_interval(interval),
locator_selection(
second,
bysecond,
Some(-1),
None,
minute_or_second_selector,
),
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
/// Locate one explicit second of each minute, including second 0.
pub fn second_locator_at(
second : Int32,
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
second_locator(
second=minute_or_second_selector(second),
interval~,
tz?,
timezone?,
)
}
///|
/// Locate ticks at a fixed number of microseconds from the first UTC midnight.
/// Include one adjacent tick beyond each end, as Matplotlib does. Ticks outside
/// years 1..9999 are omitted. Ordinal precision decreases far from 1970.
///
/// ```mbt check
/// test {
/// let locator = @date.microsecond_locator(interval=250)
/// let ticks = locator.tick_values(
/// 100.0 / 86_400_000_000.0,
/// 1000.0 / 86_400_000_000.0,
/// )
/// assert_eq(ticks.map(value => @date.from_ordinal(value).microsecond()), [
/// 0, 250, 500, 750, 1000, 1250,
/// ])
/// }
/// ```
pub fn microsecond_locator(
interval~ : Int32 = 1,
tz? : String,
timezone? : DateTimeZone,
) -> DateLocator raise DateError {
let locator = new_date_locator(
Microseconds,
positive_interval(interval),
None,
0,
)
locator.with_timezone(selected_timezone(tz, timezone))
}
///|
pub fn DateLocator::kind(self : DateLocator) -> LocatorKind {
self.kind
}
///|
/// Legacy integer interval. Fractional microsecond intervals truncate and large
/// ones saturate to Int32::MAX; interval_value() retains the exact Double value.
pub fn DateLocator::interval(self : DateLocator) -> Int32 {
if self.microsecond_object is Some(value) {
return microsecond_interval_projection(value.interval)
}
if self.wrapper_locator is Some(value) {
return value.interval()
}
self.interval
}
///|
/// Return the smallest selector, or 0 when no filter is configured.
pub fn DateLocator::selector(self : DateLocator) -> Int32 {
self.selector
}
///|
/// Return configured whole minutes, truncated toward zero for TZif history.
/// A configured `timezone` supplies the exact offsets used for expansion.
pub fn DateLocator::offset_minutes(self : DateLocator) -> Int32 {
self.offset_minutes
}
///|
/// Return the explicit transition-aware timezone used for tick conversion,
/// if configured.
pub fn DateLocator::timezone(self : DateLocator) -> DateTimeZone? {
self.timezone
}
///|
/// Return a copy of this locator whose calendar boundaries use a fixed UTC
/// offset. The emitted numeric ticks remain UTC Matplotlib ordinals.
///
/// ```mbt check
/// test {
/// let locator = try! @date.hour_locator().with_offset(90)
/// assert_eq(locator.offset_minutes(), 90)
/// }
/// ```
pub fn DateLocator::with_offset(
self : DateLocator,
offset_minutes : Int32,
) -> DateLocator raise DateError {
if offset_minutes <= -1440 || offset_minutes >= 1440 {
raise DateError::InvalidDate(
message="offset_minutes must be in -1439..1439",
)
}
{
kind: self.kind,
interval: self.interval,
selector: self.selector,
secondary: self.secondary,
selector_enabled: self.selector_enabled,
offset_minutes,
timezone: None,
microsecond_object: self.microsecond_object.map(value => {
try! value.with_offset(offset_minutes)
}),
wrapper_locator: self.wrapper_locator.map(value => {
try! value.with_offset(offset_minutes)
}),
automatic: self.automatic.map(value => {
try! value.with_offset(offset_minutes)
}),
selection: self.selection,
secondary_selection: self.secondary_selection,
holidays: self.holidays.copy(),
rule: match self.rule {
Some(rule) => Some(rule.with_offset(offset_minutes))
None => None
},
}
}
///|
/// Return a copy whose calendar candidates resolve with an explicit transition
/// table. Recurrence locators generate local candidates in the same zone, so a
/// spring gap is skipped and a fall fold can emit both UTC instants.
pub fn DateLocator::with_timezone(
self : DateLocator,
timezone : DateTimeZone,
) -> DateLocator {
{
kind: self.kind,
interval: self.interval,
selector: self.selector,
secondary: self.secondary,
selector_enabled: self.selector_enabled,
offset_minutes: timezone.initial_offset_minutes(),
timezone: Some(timezone),
microsecond_object: self.microsecond_object.map(value => {
value.with_timezone(timezone)
}),
wrapper_locator: self.wrapper_locator.map(value => {
value.with_timezone(timezone)
}),
automatic: self.automatic.map(value => value.with_timezone(timezone)),
selection: self.selection,
secondary_selection: self.secondary_selection,
holidays: self.holidays.copy(),
rule: match self.rule {
Some(rule) => Some(rule.with_timezone(timezone))
None => None
},
}
}
///|
/// Generate up to 100000 ticks in input range order. With no matching dates,
/// return the input endpoints. Invalid ordinals return an empty array.
///
/// Recurrences use an explicit `dtstart` when configured; otherwise they use
/// Matplotlib's calendar-expanded window as their anchor.
/// Year locators include aligned years outside the range. Microsecond locators
/// include adjacent multiples on either side, within years 1..9999.
pub fn DateLocator::tick_values(
self : DateLocator,
lower : Double,
upper : Double,
) -> Array[Double] {
self.tick_values_checked(lower, upper) catch {
_ => []
}
}
///|
/// Generate calendar ticks while preserving date conversion and selection errors.
/// The legacy tick_values convenience method returns [] on these errors.
pub fn DateLocator::tick_values_checked(
self : DateLocator,
lower : Double,
upper : Double,
) -> Array[Double] raise DateError {
if self.microsecond_object is Some(value) {
return value.numeric_tick_values(lower, upper)
}
if self.wrapper_locator is Some(value) {
return value.numeric_tick_values(lower, upper)
}
if self.automatic is Some(value) {
return value.tick_values(lower, upper)
}
let (first, last) = if lower > upper {
(upper, lower)
} else {
(lower, upper)
}
let first_date = match self.timezone {
Some(zone) => zone.to_datetime(first)
None => from_ordinal(first, offset_minutes=self.offset_minutes)
}
let last_date = match self.timezone {
Some(zone) => zone.to_datetime(last)
None => from_ordinal(last, offset_minutes=self.offset_minutes)
}
self.ticks_for_dates(first, last, first_date, last_date, lower <= upper)
}
///|
// The automatic view path already decoded and rounded endpoints. Keep those
// calendar fields while clipping emitted date2num coordinates, without a second
// public inverse conversion (which would add a fractional epoch again).
fn DateLocator::ticks_for_dates(
self : DateLocator,
first : Double,
last : Double,
first_date : DateTime,
last_date : DateTime,
ascending : Bool,
) -> Array[Double] {
if first == last && !(self.kind is Microseconds) {
return [first]
}
let values = match self.kind {
Years => year_ticks(self, first_date, last_date)
NthWeekdays => nth_weekday_ticks(self, first, last, first_date, last_date)
Recurrence => recurrence_ticks(self, first, last, first_date, last_date)
Microseconds => microsecond_ticks(first, last, self.interval)
_ => {
let anchor = recurrence_start(first_date, last_date)
match self.kind {
Months => month_ticks(self, first, last, first_date, last_date, anchor)
Days | Weekdays | BusinessDays =>
locator_day_ticks(self, first, last, first_date, last_date, anchor)
Hours =>
time_ticks(self, first, last, first_date, last_date, anchor, 3600, 24)
Minutes =>
time_ticks(self, first, last, first_date, last_date, anchor, 60, 60)
Seconds =>
time_ticks(self, first, last, first_date, last_date, anchor, 1, 60)
Microseconds => []
NthWeekdays => []
Recurrence => []
Years => []
Automatic => []
}
}
}
if values.is_empty() {
if ascending {
[first, last]
} else {
[last, first]
}
} else if ascending {
values
} else {
values.rev()
}
}