diff --git a/README.md b/README.md index dceea5b4..f9dee700 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,10 @@ Like [d3.tickStep](#tickStep), except requires that *start* is always less than Returns the difference between adjacent tick values if the same arguments were passed to [d3.ticks](#ticks): a nicely-rounded value that is a power of ten multiplied by 1, 2 or 5. Note that due to the limited precision of IEEE 754 floating point, the returned value may not be exact decimals; use [d3-format](https://github.com/d3/d3-format) to format numbers for human consumption. +# d3.nice(start, stop, count) + +Returns a new interval [*niceStart*, *niceStop*] covering the given interval [*start*, *stop*] and where *niceStart* and *niceStop* are guaranteed to align with the corresponding [tick step](#tickStep). Like [d3.tickIncrement](#tickIncrement), this requires that *start* is less than or equal to *stop*. + # d3.range([start, ]stop[, step]) · [Source](https://github.com/d3/d3-array/blob/master/src/range.js), [Examples](https://observablehq.com/@d3/d3-range) Returns an array containing an arithmetic progression, similar to the Python built-in [range](http://docs.python.org/library/functions.html#range). This method is often used to iterate over a sequence of uniformly-spaced numeric values, such as the indexes of an array or the ticks of a linear scale. (See also [d3.ticks](#ticks) for nicely-rounded values.) @@ -656,6 +660,8 @@ You can then compute the bins from an array of numbers like so: var bins = bin(numbers); ``` +If the default [extent](#extent) domain is used and the [thresholds](#bin_thresholds) are specified as a count (rather than explicit values), then the computed domain will be [niced](#nice) such that all bins are uniform width. + Note that the domain accessor is invoked on the materialized array of [values](#bin_value), not on the input data array. # bin.thresholds([count]) · [Source](https://github.com/d3/d3-array/blob/master/src/bin.js), [Examples](https://observablehq.com/@d3/d3-bin) diff --git a/src/bin.js b/src/bin.js index ea69ae56..62ff5ca2 100644 --- a/src/bin.js +++ b/src/bin.js @@ -3,6 +3,7 @@ import bisect from "./bisect.js"; import constant from "./constant.js"; import extent from "./extent.js"; import identity from "./identity.js"; +import nice from "./nice.js"; import ticks from "./ticks.js"; import sturges from "./threshold/sturges.js"; @@ -28,8 +29,11 @@ export default function() { x1 = xz[1], tz = threshold(values, x0, x1); - // Convert number of thresholds into uniform thresholds. + // Convert number of thresholds into uniform thresholds, + // and nice the default domain accordingly. if (!Array.isArray(tz)) { + tz = +tz; + if (domain === extent) [x0, x1] = nice(x0, x1, tz); tz = ticks(x0, x1, tz); if (tz[tz.length - 1] === x1) tz.pop(); // exclusive } diff --git a/src/index.js b/src/index.js index 449f608b..92d41d77 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ export {default as median} from "./median.js"; export {default as merge} from "./merge.js"; export {default as min} from "./min.js"; export {default as minIndex} from "./minIndex.js"; +export {default as nice} from "./nice.js"; export {default as pairs} from "./pairs.js"; export {default as permute} from "./permute.js"; export {default as quantile, quantileSorted} from "./quantile.js"; diff --git a/src/nice.js b/src/nice.js new file mode 100644 index 00000000..378243a2 --- /dev/null +++ b/src/nice.js @@ -0,0 +1,19 @@ +import {tickIncrement} from "./ticks.js"; + +export default function nice(start, stop, count) { + let prestep; + while (true) { + const step = tickIncrement(start, stop, count); + if (step === prestep || step === 0 || !isFinite(step)) { + break; + } else if (step > 0) { + start = Math.floor(start / step) * step; + stop = Math.ceil(stop / step) * step; + } else if (step < 0) { + start = Math.ceil(start * step) / step; + stop = Math.floor(stop * step) / step; + } + prestep = step; + } + return [start, stop]; +} diff --git a/test/bin-test.js b/test/bin-test.js index 5243c70f..b1b6b766 100644 --- a/test/bin-test.js +++ b/test/bin-test.js @@ -140,11 +140,11 @@ tape("bin(data) uses nice thresholds", (test) => { tape("bin()() returns bins whose rightmost bin is not too wide", (test) => { const h = d3.bin(); test.deepEqual(h([9.8, 10, 11, 12, 13, 13.2]), [ - bin([9.8], 9.8, 10), + bin([9.8], 9, 10), bin([10], 10, 11), bin([11], 11, 12), bin([12], 12, 13), - bin([13, 13.2], 13, 13.2), + bin([13, 13.2], 13, 14), ]); }); diff --git a/test/nice-test.js b/test/nice-test.js new file mode 100644 index 00000000..7dd5a49e --- /dev/null +++ b/test/nice-test.js @@ -0,0 +1,46 @@ +const tape = require("tape-await"); +const array = require("../"); + +tape("nice(start, stop, count) returns [start, stop] if any argument is NaN", (test) => { + test.deepEqual(array.nice(NaN, 1, 1), [NaN, 1]); + test.deepEqual(array.nice(0, NaN, 1), [0, NaN]); + test.deepEqual(array.nice(0, 1, NaN), [0, 1]); + test.deepEqual(array.nice(NaN, NaN, 1), [NaN, NaN]); + test.deepEqual(array.nice(0, NaN, NaN), [0, NaN]); + test.deepEqual(array.nice(NaN, 1, NaN), [NaN, 1]); + test.deepEqual(array.nice(NaN, NaN, NaN), [NaN, NaN]); +}); + +tape("nice(start, stop, count) returns [start, stop] if start === stop", (test) => { + test.deepEqual(array.nice(1, 1, -1), [1, 1]); + test.deepEqual(array.nice(1, 1, 0), [1, 1]); + test.deepEqual(array.nice(1, 1, NaN), [1, 1]); + test.deepEqual(array.nice(1, 1, 1), [1, 1]); + test.deepEqual(array.nice(1, 1, 10), [1, 1]); +}); + +tape("nice(start, stop, count) returns [start, stop] if count is not positive", (test) => { + test.deepEqual(array.nice(0, 1, -1), [0, 1]); + test.deepEqual(array.nice(0, 1, 0), [0, 1]); +}); + +tape("nice(start, stop, count) returns [start, stop] if count is infinity", (test) => { + test.deepEqual(array.nice(0, 1, Infinity), [0, 1]); +}); + +tape("nice(start, stop, count) returns the expected values", (test) => { + test.deepEqual(array.nice(0.132, 0.876, 1000), [0.132, 0.876]); + test.deepEqual(array.nice(0.132, 0.876, 100), [0.13, 0.88]); + test.deepEqual(array.nice(0.132, 0.876, 30), [0.12, 0.88]); + test.deepEqual(array.nice(0.132, 0.876, 10), [0.1, 0.9]); + test.deepEqual(array.nice(0.132, 0.876, 6), [0.1, 0.9]); + test.deepEqual(array.nice(0.132, 0.876, 5), [0, 1]); + test.deepEqual(array.nice(0.132, 0.876, 1), [0, 1]); + test.deepEqual(array.nice(132, 876, 1000), [132, 876]); + test.deepEqual(array.nice(132, 876, 100), [130, 880]); + test.deepEqual(array.nice(132, 876, 30), [120, 880]); + test.deepEqual(array.nice(132, 876, 10), [100, 900]); + test.deepEqual(array.nice(132, 876, 6), [100, 900]); + test.deepEqual(array.nice(132, 876, 5), [0, 1000]); + test.deepEqual(array.nice(132, 876, 1), [0, 1000]); +}); diff --git a/test/tickIncrement-test.js b/test/tickIncrement-test.js new file mode 100644 index 00000000..c14c0fb1 --- /dev/null +++ b/test/tickIncrement-test.js @@ -0,0 +1,62 @@ +const tape = require("tape-await"); +const array = require("../"); + +tape("tickIncrement(start, stop, count) returns NaN if any argument is NaN", (test) => { + test.ok(isNaN(array.tickIncrement(NaN, 1, 1))); + test.ok(isNaN(array.tickIncrement(0, NaN, 1))); + test.ok(isNaN(array.tickIncrement(0, 1, NaN))); + test.ok(isNaN(array.tickIncrement(NaN, NaN, 1))); + test.ok(isNaN(array.tickIncrement(0, NaN, NaN))); + test.ok(isNaN(array.tickIncrement(NaN, 1, NaN))); + test.ok(isNaN(array.tickIncrement(NaN, NaN, NaN))); +}); + +tape("tickIncrement(start, stop, count) returns NaN or -Infinity if start === stop", (test) => { + test.ok(isNaN(array.tickIncrement(1, 1, -1))); + test.ok(isNaN(array.tickIncrement(1, 1, 0))); + test.ok(isNaN(array.tickIncrement(1, 1, NaN))); + test.equal(array.tickIncrement(1, 1, 1), -Infinity); + test.equal(array.tickIncrement(1, 1, 10), -Infinity); +}); + +tape("tickIncrement(start, stop, count) returns 0 or Infinity if count is not positive", (test) => { + test.equal(array.tickIncrement(0, 1, -1), Infinity); + test.equal(array.tickIncrement(0, 1, 0), Infinity); +}); + +tape("tickIncrement(start, stop, count) returns -Infinity if count is infinity", (test) => { + test.equal(array.tickIncrement(0, 1, Infinity), -Infinity); +}); + +tape("tickIncrement(start, stop, count) returns approximately count + 1 tickIncrement when start < stop", (test) => { + test.equal(array.tickIncrement( 0, 1, 10), -10); + test.equal(array.tickIncrement( 0, 1, 9), -10); + test.equal(array.tickIncrement( 0, 1, 8), -10); + test.equal(array.tickIncrement( 0, 1, 7), -5); + test.equal(array.tickIncrement( 0, 1, 6), -5); + test.equal(array.tickIncrement( 0, 1, 5), -5); + test.equal(array.tickIncrement( 0, 1, 4), -5); + test.equal(array.tickIncrement( 0, 1, 3), -2); + test.equal(array.tickIncrement( 0, 1, 2), -2); + test.equal(array.tickIncrement( 0, 1, 1), 1); + test.equal(array.tickIncrement( 0, 10, 10), 1); + test.equal(array.tickIncrement( 0, 10, 9), 1); + test.equal(array.tickIncrement( 0, 10, 8), 1); + test.equal(array.tickIncrement( 0, 10, 7), 2); + test.equal(array.tickIncrement( 0, 10, 6), 2); + test.equal(array.tickIncrement( 0, 10, 5), 2); + test.equal(array.tickIncrement( 0, 10, 4), 2); + test.equal(array.tickIncrement( 0, 10, 3), 5); + test.equal(array.tickIncrement( 0, 10, 2), 5); + test.equal(array.tickIncrement( 0, 10, 1), 10); + test.equal(array.tickIncrement(-10, 10, 10), 2); + test.equal(array.tickIncrement(-10, 10, 9), 2); + test.equal(array.tickIncrement(-10, 10, 8), 2); + test.equal(array.tickIncrement(-10, 10, 7), 2); + test.equal(array.tickIncrement(-10, 10, 6), 5); + test.equal(array.tickIncrement(-10, 10, 5), 5); + test.equal(array.tickIncrement(-10, 10, 4), 5); + test.equal(array.tickIncrement(-10, 10, 3), 5); + test.equal(array.tickIncrement(-10, 10, 2), 10); + test.equal(array.tickIncrement(-10, 10, 1), 20); +});