charts.ticks
Axis ticks: a round step (1, 2 or 5 x 10^k), the ticks on it, and calendar ticks from days to years.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 57 tests, run in TypeScript, Python and Rust.tickStep 16 · niceTicks 12 · timeTickInterval 12 · timeTicks 17
What it does
Where the ticks on an axis go. `niceTicks(0, 1, 10)` is `[0, 0.1, ..., 1]`, `timeTicks("2026-01-01", "2026-12-31", "month", 3)` is the four quarter starts, and `tickStep` and `timeTickInterval` say what spacing to use for about `count` ticks. This is a group: the four functions are the tick half of an axis, used with `charts.scale` and `charts.format`.
## Number ticks
The functions
A group: 4 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- tick_step (start: float, stop: float, count: int) -> float
- nice_ticks (start: float, stop: float, count: int) -> float[]
- time_tick_interval (start: date, stop: date, count: int) -> TimeTickInterval
- time_ticks (start: date, stop: date, interval: TimeInterval, step: int) -> date[]
The types it declares, generated into your project
// TimeInterval is a string in Rust, one of: "day", "week", "month", "quarter", "year".
// Parameters take it as &str and results hold it as String.
/// A calendar interval and how many of it between ticks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeTickInterval {
pub interval: String,
/// 1 or more
pub step: i64,
}
Once installed, your code imports each one from the group's module.
tick_step throws on bad input 16 tests
pub fn tick_step(start: f64, stop: f64, count: i64) -> f64
| start | float | |
| stop | float | |
| count | int | roughly how many ticks are wanted, at least 1 |
| returns | float | 1, 2 or 5 times a power of ten, exactly the nearest double to it (0.1, not 0.1000000000000001); 0 when start equals stop |
For example
tick_step(0, 10, 10)→ 1 ten ticks over tentick_step(0, 100, 10)→ 10 ten ticks over a hundredtick_step(0, 1, 10)→ 0.1 a tenth, exactly the double 0.1
fune!(charts.ticks@^1); // then call tick_step(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// The tick step for about `count` ticks: 1, 2 or 5 times a power of ten, as
/// d3-array's tickIncrement chooses it, found without log10 and returned as one
/// correctly rounded division so 0.2 is exactly the double 0.2.
///
/// # Panics
/// Panics if start or stop is not finite or `count` is less than 1.
pub fn tick_step(start: f64, stop: f64, count: i64) -> f64 {
let (mul, div) = tick_spec(start, stop, count);
mul / div
}
// Exported for nice_ticks, which needs the step as a multiplier or a divisor.
/// The step as (multiplier, divisor), one of which is 1; (0, 1) when start
/// equals stop.
pub fn tick_spec(start: f64, stop: f64, count: i64) -> (f64, f64) {
if !start.is_finite() || !stop.is_finite() {
panic!("start and stop must be finite numbers; got {} and {}", start, stop);
}
if count < 1 {
panic!("count must be a whole number of at least 1, got {}", count);
}
let raw = (stop - start).abs() / count as f64;
if raw == 0.0 {
return (0.0, 1.0);
}
if raw >= 1.0 {
let mut power = 1.0;
while power * 10.0 <= raw {
power *= 10.0;
}
return (nice_factor(raw / power) * power, 1.0);
}
let mut inverse = 1.0;
while raw * inverse < 1.0 {
inverse *= 10.0;
}
let factor = nice_factor(raw * inverse);
if factor == inverse {
(1.0, 1.0)
} else {
(1.0, inverse / factor)
}
}
fn nice_factor(error: f64) -> f64 {
if error >= 50f64.sqrt() {
10.0
} else if error >= 10f64.sqrt() {
5.0
} else if error >= 2f64.sqrt() {
2.0
} else {
1.0
}
}
pub fn fune_vector(args: &[Value]) -> Value {
// Refuse what the typed signature cannot hold, with the wording TypeScript
// and Python use, rather than let the conversion below quietly change it.
if matches!(args[2], Value::Float(f) if f.fract() != 0.0) {
panic!("count must be a whole number of at least 1");
}
Value::Float(tick_step(args[0].as_f64(), args[1].as_f64(), args[2].as_i64()))
}nice_ticks throws on bad input 12 tests
pub fn nice_ticks(start: f64, stop: f64, count: i64) -> Vec<f64>
| start | float | |
| stop | float | |
| count | int | roughly how many ticks are wanted, at least 1 |
| returns | float[] | the multiples of tickStep between start and stop inclusive, running from start towards stop |
For example
nice_ticks(0, 10, 5)→ 0, 2, 4, 6, 8, 10 steps of twonice_ticks(0, 1, 10)→ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1 tenths are exact: the fourth tick is 0.3, not 0.30000000000000004nice_ticks(0.13, 0.87, 5)→ 0.2, 0.4, 0.6, 0.8 ticks inside a messy extent
fune!(charts.ticks@^1); // then call nice_ticks(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_ticks_tick_step::tick_spec; ← tickStep, another function of this group · built into the same file, even by a slim install
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// Ticks at round values between start and stop, as d3.ticks, each one exact
/// operation on a whole number (i * step or i / divisor), so 0.1 steps give 0.3
/// and not 0.30000000000000004.
///
/// # Panics
/// Panics if start or stop is not finite or `count` is less than 1.
pub fn nice_ticks(start: f64, stop: f64, count: i64) -> Vec<f64> {
let (mut mul, mut div) = tick_spec(start, stop, count);
if start == stop {
return vec![start + 0.0];
}
let reverse = stop < start;
let (lo, hi) = if reverse { (stop, start) } else { (start, stop) };
let (mut i1, mut i2) = indexes(lo, hi, mul, div);
if i2 < i1 && count == 1 {
// No multiple of the step for one tick falls inside; d3 retries with two.
let spec = tick_spec(start, stop, 2);
mul = spec.0;
div = spec.1;
let idx = indexes(lo, hi, mul, div);
i1 = idx.0;
i2 = idx.1;
}
let mut ticks = Vec::new();
let mut i = i1;
while i <= i2 {
ticks.push((if div > 1.0 { i / div } else { i * mul }) + 0.0);
i += 1.0;
}
if reverse {
ticks.reverse();
}
ticks
}
fn indexes(lo: f64, hi: f64, mul: f64, div: f64) -> (f64, f64) {
if div > 1.0 {
let mut i1 = round_float(lo * div, 0);
let mut i2 = round_float(hi * div, 0);
if i1 / div < lo {
i1 += 1.0;
}
if i2 / div > hi {
i2 -= 1.0;
}
return (i1, i2);
}
let mut i1 = round_float(lo / mul, 0);
let mut i2 = round_float(hi / mul, 0);
if i1 * mul < lo {
i1 += 1.0;
}
if i2 * mul > hi {
i2 -= 1.0;
}
(i1, i2)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Arr(
nice_ticks(args[0].as_f64(), args[1].as_f64(), args[2].as_i64())
.into_iter()
.map(Value::Float)
.collect(),
)
}time_tick_interval throws on bad input 12 tests
pub fn time_tick_interval(start: &str, stop: &str, count: i64) -> TimeTickInterval
| start | date | |
| stop | date | |
| count | int | roughly how many ticks are wanted, at least 1 |
| returns | TimeTickInterval |
For example
time_tick_interval(2026-01-01, 2026-01-11, 10)→ interval day, step 1 ten days, ten ticks: dailytime_tick_interval(2026-01-01, 2026-01-31, 10)→ interval day, step 2 a month, ten ticks: every other daytime_tick_interval(2026-01-01, 2026-04-01, 10)→ interval week, step 1 a quarter, ten ticks: weekly
fune!(charts.ticks@^1); // then call time_tick_interval(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_ticks_tick_step::tick_step; ← tickStep, another function of this group · built into the same file, even by a slim install
use super::dates_add_days::epoch_day_from_iso; ← from dates.add-days ^1.0.0 · built alongside by fune
// d3-scale's calendar ladder, from a day up, with the lengths in days it
// compares against: a month counts as 30, a quarter as 90, a year as 365.
const LADDER: [(&str, i64, f64); 6] = [
("day", 1, 1.0),
("day", 2, 2.0),
("week", 1, 7.0),
("month", 1, 30.0),
("quarter", 1, 90.0),
("year", 1, 365.0),
];
/// The calendar interval for about `count` ticks between two dates, as d3's
/// time scale picks it: the nearest rung of the ladder by ratio, or whole years
/// in steps of 1, 2 or 5 x 10^k beyond a year.
///
/// # Panics
/// Panics on a date that is not a real ISO date or `count` less than 1.
pub fn time_tick_interval(start: &str, stop: &str, count: i64) -> TimeTickInterval {
if count < 1 {
panic!("count must be a whole number of at least 1, got {}", count);
}
let span = (epoch_day_from_iso(stop) - epoch_day_from_iso(start)).abs() as f64;
let target = span / count as f64;
let mut i = 0;
while i < LADDER.len() && LADDER[i].2 <= target {
i += 1;
}
if i == LADDER.len() {
let step = tick_step(0.0, span / 365.0, count) as i64;
return TimeTickInterval { interval: "year".to_string(), step: step.max(1) };
}
if i == 0 {
return TimeTickInterval { interval: "day".to_string(), step: 1 };
}
let pick = if target / LADDER[i - 1].2 < LADDER[i].2 / target { LADDER[i - 1] } else { LADDER[i] };
TimeTickInterval { interval: pick.0.to_string(), step: pick.1 }
}
pub fn time_tick_interval_to_value(t: &TimeTickInterval) -> Value {
Value::obj(vec![("interval", Value::str(&t.interval)), ("step", Value::Int(t.step))])
}
pub fn fune_vector(args: &[Value]) -> Value {
time_tick_interval_to_value(&time_tick_interval(args[0].as_str(), args[1].as_str(), args[2].as_i64()))
}time_ticks throws on bad input 17 tests
pub fn time_ticks(start: &str, stop: &str, interval: &str, step: i64) -> Vec<String>
| start | date | |
| stop | date | |
| interval | TimeInterval | day, week (Mondays), month, quarter or year |
| step | int | every step-th boundary, at least 1: day 2 is the 1st, 3rd, 5th... of each month; month 3 is January, April, July, October |
| returns | date[] | the boundaries between start and stop inclusive, running from start towards stop |
For example
time_ticks(2026-01-30, 2026-02-02, day, 1)→ 2026-01-30, 2026-01-31, 2026-02-01, 2026-02-02 every day across a month endtime_ticks(2026-01-28, 2026-02-04, day, 2)→ 2026-01-29, 2026-01-31, 2026-02-01, 2026-02-03 every other day follows the day of the month, restarting on the 1sttime_ticks(2024-02-28, 2024-03-01, day, 1)→ 2024-02-28, 2024-02-29, 2024-03-01 a leap day
fune!(charts.ticks@^1); // then call time_ticks(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{civil_from_days, days_from_civil, epoch_day_from_iso, iso_from_epoch_day}; ← from dates.add-days ^1.0.0 · built alongside by fune
// 1970-01-05, the first Monday on or after the epoch, as a day number.
const FIRST_MONDAY: i64 = 4;
const MAX_TICKS: usize = 10000;
fn ceil_div(a: i64, b: i64) -> i64 {
-((-a).div_euclid(b))
}
/// Calendar boundaries between two dates, inclusive, anchored to the calendar
/// rather than to `start` so that panning does not move them: days by
/// (day - 1) % step, weeks since 1970-01-05, months by (month - 1) % step,
/// quarters by quarter of the year, years by year % step.
///
/// # Panics
/// Panics on a date that is not a real ISO date, an unknown interval, `step`
/// less than 1, or more than 10,000 ticks.
pub fn time_ticks(start: &str, stop: &str, interval: &str, step: i64) -> Vec<String> {
if step < 1 {
panic!("step must be a whole number of at least 1, got {}", step);
}
let a = epoch_day_from_iso(start);
let b = epoch_day_from_iso(stop);
let reverse = b < a;
let (lo, hi) = if reverse { (b, a) } else { (a, b) };
let mut days: Vec<i64> = Vec::new();
let add = |day: i64, days: &mut Vec<i64>| {
if days.len() >= MAX_TICKS {
panic!("too many ticks: more than {}; use a longer interval or step", MAX_TICKS);
}
days.push(day);
};
match interval {
"day" => {
for d in lo..=hi {
if (civil_from_days(d).day - 1) % step == 0 {
add(d, &mut days);
}
}
}
"week" => {
let mut k = ceil_div(lo - FIRST_MONDAY, 7);
k = ceil_div(k, step) * step;
let mut d = FIRST_MONDAY + 7 * k;
while d <= hi {
add(d, &mut days);
d += 7 * step;
}
}
"month" | "quarter" | "year" => {
let months = match interval {
"month" => step,
"quarter" => 3 * step,
_ => 12,
};
let first = civil_from_days(lo);
let (mut year, mut month) = (first.year, first.month);
loop {
let d = days_from_civil(year, month, 1);
if d > hi {
break;
}
let on_step = if interval == "year" {
month == 1 && year % step == 0
} else {
(month - 1) % months == 0
};
if d >= lo && on_step {
add(d, &mut days);
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
}
other => panic!("unknown time interval \"{}\"", other),
}
let mut ticks: Vec<String> = days.into_iter().map(iso_from_epoch_day).collect();
if reverse {
ticks.reverse();
}
ticks
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Arr(
time_ticks(args[0].as_str(), args[1].as_str(), args[2].as_str(), args[3].as_i64())
.iter()
.map(|s| Value::str(s))
.collect(),
)
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add charts.ticks
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add charts.ticks --only tickStep
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.ticks-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.ticks@1.0.0:rust.
The whole function, every language, is one file too: charts.ticks-1.0.0.fune, 42,760 bytes, sha256 c5ce85e5c19c171b83a7de93b99f92ae8d636f869f8cc071d3ef2bb16c333991. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before charts.ticks.tickStep
// fune: before charts.ticks.niceTicks
// fune: before charts.ticks.timeTickInterval
// fune: before charts.ticks.timeTicks
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.ticks.tickStep
// fune: after charts.ticks.niceTicks
// fune: after charts.ticks.timeTickInterval
// fune: after charts.ticks.timeTicks
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.add-days in charts.ticks
// fune: replace math.round-float in charts.ticks
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.ticks --steps.
// fune: step charts.ticks.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
tickStep 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| ten ticks over ten | 0, 10, 10 | → | 1 |
| ten ticks over a hundred | 0, 100, 10 | → | 10 |
| a tenth, exactly the double 0.1 | 0, 1, 10 | → | 0.1 |
| a fifth, as 1/5 rather than 0.1 * 2 | 0, 1, 5 | → | 0.2 |
| 9.7 rounds up to 10 | 0, 97, 10 | → | 10 |
| 3.7 rounds to 5 | 0, 37, 10 | → | 5 |
| 1.5 rounds to 2 | 0, 15, 10 | → | 2 |
| negative to positive | -12.5, 37.2, 5 | → | 10 |
| a reversed extent still has a positive step | 100, 0, 10 | → | 10 |
| a small fractional step | 0, 0.05, 10 | → | 0.005 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 0.7 for one tick rounds to 0.5 | 0, 0.7, 1 | → | 0.5 |
| 0.9 for one tick rounds up to a whole 1 | 0, 0.9, 1 | → | 1 |
| large values | 0, 1,234,567, 5 | → | 200,000 |
| equal ends have no step | 5, 5, 10 | → | 0 |
| count of zero is an error | 0, 10, 0 | → | error: count must be a whole number of at least 1 |
| fractional count is an error | 0, 10, 1.5 | → | error: count must be a whole number of at least 1 |
niceTicks 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| steps of two | 0, 10, 5 | → | 0, 2, 4, 6, 8, 10 |
| tenths are exact: the fourth tick is 0.3, not 0.30000000000000004 | 0, 1, 10 | → | 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1 |
| ticks inside a messy extent | 0.13, 0.87, 5 | → | 0.2, 0.4, 0.6, 0.8 |
| negative to positive, the ends are not ticks | -12.5, 37.2, 5 | → | -10, 0, 10, 20, 30 |
| a reversed extent gives ticks in its direction | 10, 0, 5 | → | 10, 8, 6, 4, 2, 0 |
| equal ends give that one value | 5, 5, 10 | → | 5 |
| one tick | 1.2, 3.8, 1 | → | 2 |
| one tick with no whole number inside retries at two, as d3 does | 2.1, 2.9, 1 | → | 2.5 |
| millions | 0, 1,000,000, 4 | → | 0, 200,000, 400,000, 600,000, 800,000, 1,000,000 |
| thousandths | 0.001, 0.006, 4 | → | 0.001, 0.002, 0.003, 0.004, 0.005 |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| all negative | -1, -0.5, 5 | → | -1, -0.9, -0.8, -0.7, -0.6, -0.5 |
| count of zero is an error | 0, 10, 0 | → | error: count must be a whole number of at least 1 |
timeTickInterval 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| ten days, ten ticks: daily | 2026-01-01, 2026-01-11, 10 | → | interval day, step 1 |
| a month, ten ticks: every other day | 2026-01-01, 2026-01-31, 10 | → | interval day, step 2 |
| a quarter, ten ticks: weekly | 2026-01-01, 2026-04-01, 10 | → | interval week, step 1 |
| a year, ten ticks: monthly | 2026-01-01, 2027-01-01, 10 | → | interval month, step 1 |
| a reversed year is the same | 2027-01-01, 2026-01-01, 10 | → | interval month, step 1 |
| two years, five ticks: quarterly | 2026-01-01, 2028-01-01, 5 | → | interval quarter, step 1 |
| a hundred days for one tick is nearer a quarter than a year | 2026-01-01, 2026-04-11, 1 | → | interval quarter, step 1 |
| a decade, five ticks: every two years | 2020-01-01, 2030-01-01, 5 | → | interval year, step 2 |
| fifty years, five ticks: every ten years | 1975-01-01, 2025-01-01, 5 | → | interval year, step 10 |
| the same day: daily | 2026-01-01, 2026-01-01, 5 | → | interval day, step 1 |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| count of zero is an error | 2026-01-01, 2027-01-01, 0 | → | error: count must be a whole number of at least 1 |
| an impossible date is an error | 2026-02-30, 2027-01-01, 5 | → | error: is not a real calendar date |
timeTicks 17 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| every day across a month end | 2026-01-30, 2026-02-02, day, 1 | → | 2026-01-30, 2026-01-31, 2026-02-01, 2026-02-02 |
| every other day follows the day of the month, restarting on the 1st | 2026-01-28, 2026-02-04, day, 2 | → | 2026-01-29, 2026-01-31, 2026-02-01, 2026-02-03 |
| a leap day | 2024-02-28, 2024-03-01, day, 1 | → | 2024-02-28, 2024-02-29, 2024-03-01 |
| Mondays in September 2026 | 2026-09-01, 2026-09-30, week, 1 | → | 2026-09-07, 2026-09-14, 2026-09-21, 2026-09-28 |
| every other Monday, counted from 1970-01-05 | 2026-09-01, 2026-09-30, week, 2 | → | 2026-09-14, 2026-09-28 |
| Mondays either side of 1970 | 1969-12-29, 1970-01-06, week, 1 | → | 1969-12-29, 1970-01-05 |
| month starts, the first after a mid-month start | 2026-01-15, 2026-05-01, month, 1 | → | 2026-02-01, 2026-03-01, 2026-04-01, 2026-05-01 |
| every third month is January, April, July, October | 2026-01-01, 2026-12-31, month, 3 | → | 2026-01-01, 2026-04-01, 2026-07-01, 2026-10-01 |
| quarter starts across a year end | 2025-11-15, 2026-08-01, quarter, 1 | → | 2026-01-01, 2026-04-01, 2026-07-01 |
| every other quarter | 2026-01-01, 2027-01-01, quarter, 2 | → | 2026-01-01, 2026-07-01, 2027-01-01 |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 1 January each year | 2023-06-01, 2026-01-01, year, 1 | → | 2024-01-01, 2025-01-01, 2026-01-01 |
| every fifth year falls on multiples of five | 2001-03-01, 2021-12-31, year, 5 | → | 2005-01-01, 2010-01-01, 2015-01-01, 2020-01-01 |
| a reversed extent gives ticks in its direction | 2026-02-02, 2026-01-30, day, 1 | → | 2026-02-02, 2026-02-01, 2026-01-31, 2026-01-30 |
| no month starts inside | 2026-01-02, 2026-01-30, month, 1 | → | |
| step of zero is an error | 2026-01-01, 2026-02-01, day, 0 | → | error: step must be a whole number of at least 1 |
| an unknown interval is an error | 2026-01-01, 2026-02-01, hour, 1 | → | error: unknown time interval "hour" |
| more than 10,000 ticks is an error | 2000-01-01, 2030-01-01, day, 1 | → | error: too many ticks: more than 10000 |
More from the author
`tickStep` is d3-array's rule: divide the extent by `count`, take the power of ten at or below it, and round the leftover factor to 1, 2, 5 or 10 on a log scale (the cut-offs are √2, √10 and √50). `niceTicks` returns every multiple of that step between the two ends inclusive, in the direction from `start` to `stop`; the ends themselves are ticks only if they are multiples. Equal ends give `[start]` (and a step of 0). If one tick is asked for and no multiple falls inside, it tries again for two, as d3 does.
The arithmetic is chosen so that the three languages print the same ticks:
- The power of ten is found by multiplying whole numbers, never with `log10`, whose last bit is platform-dependent. - A fractional step is kept as a whole-number divisor: a step of 0.2 is "divide by 5", so the tick is `i / 5`, one correctly rounded division. Adding 0.1 three times gives 0.30000000000000004; here the third tenth is exactly the double 0.3. `tickStep` returns `factor / 10^k` the same way. - Tick indexes are rounded with `math.round-float` and then corrected by comparison, so a product that lands a hair either side of a whole number cannot add or lose a tick.
## Calendar ticks
`timeTicks` returns the calendar boundaries inside a date range, inclusive:
| interval | a tick on | `step` counts | |-----------|----------------------------------------|----------------------------------------| | `day` | days with (day of month - 1) % step = 0 | restarts each month, as d3's timeDay.every | | `week` | Mondays (ISO weeks) | whole weeks since Monday 1970-01-05 | | `month` | the 1st, (month - 1) % step = 0 | month 3 is January, April, July, October | | `quarter` | 1 January, April, July, October | quarter of the year % step = 0 | | `year` | 1 January, year % step = 0 | every fifth year is 2005, 2010, ... |
Steps are anchored to the calendar, not to the first date, so an axis that pans by a day keeps the same ticks instead of shifting them all. Dates are counted as whole days (via `dates.add-days`), never through a `Date` object, so time zones and clock changes cannot move a tick. More than 10,000 ticks is an error, since it is always a wrong interval.
`timeTickInterval` picks the interval for about `count` ticks the way d3's time scale does: the ideal spacing is the span in days over `count`, and it takes whichever neighbouring rung of the ladder day, 2 days, week, month (30 days), quarter (90 days), year (365 days) is nearer by ratio. Beyond a year it uses whole years in a `tickStep` of 1, 2 or 5 x 10^k.
Sources: Mike Bostock, d3-array `ticks.js` (tickIncrement, ticks) and d3-scale `time.js` (tickIntervals), github.com/d3; ISO 8601 for weeks starting on Monday.