Skip to content

Commit

Permalink
Guarantee exact tick values.
Browse files Browse the repository at this point in the history
Adds d3.tickIncrement. Ticks are now computed in integer space and then either
multiplied or divided by the tick increment. Fixes #45.
  • Loading branch information
mbostock committed Apr 15, 2017
1 parent 57cdbdd commit 04cf783
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 78 deletions.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,21 @@ Randomizes the order of the specified *array* using the [Fisher–Yates shuffle]

<a name="ticks" href="#ticks">#</a> d3.<b>ticks</b>(<i>start</i>, <i>stop</i>, <i>count</i>) [<>](https://github.com/d3/d3-array/blob/master/src/ticks.js "Source")

Returns an array of approximately *count* + 1 uniformly-spaced, nicely-rounded values between *start* and *stop* (inclusive). Each value is a power of ten multiplied by 1, 2 or 5. See also [tickStep](#tickStep) and [*linear*.ticks](https://github.com/d3/d3-scale#linear_ticks). Note that due to the limited precision of IEEE 754 floating point, the returned values may not be exact decimals; use [d3-format](https://github.com/d3/d3-format) to format numbers for human consumption.
Returns an array of approximately *count* + 1 uniformly-spaced, nicely-rounded values between *start* and *stop* (inclusive). Each value is a power of ten multiplied by 1, 2 or 5. See also [d3.tickIncrement](#tickIncrement), [d3.tickStep](#tickStep) and [*linear*.ticks](https://github.com/d3/d3-scale/blob/master/README.md#linear_ticks).

Ticks are inclusive in the sense that they may include the specified *start* and *stop* values if (and only if) they are exact, nicely-rounded values consistent with the inferred [step](#tickStep). More formally, each returned tick *t* satisfies *start**t* and *t**stop*.

<a name="tickIncrement" href="#tickIncrement">#</a> d3.<b>tickIncrement</b>(<i>start</i>, <i>stop</i>, <i>count</i>) [<>](https://github.com/d3/d3-array/blob/master/src/ticks.js#L16 "Source")

Like [d3.tickStep](#tickStep), except requires that *start* is always less than or equal to *step*, and if the tick step for the given *start*, *stop* and *count* would be less than one, returns the negative inverse tick step instead. This method is always guaranteed to return an integer, and is used by [d3.ticks](#ticks) to avoid guarantee that the returned tick values are represented as precisely as possible in IEEE 754 floating point.

<a name="tickStep" href="#tickStep">#</a> d3.<b>tickStep</b>(<i>start</i>, <i>stop</i>, <i>count</i>) [<>](https://github.com/d3/d3-array/blob/master/src/ticks.js#L16 "Source")

Returns the difference between adjacent tick values if the same arguments were passed to [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.
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.

<a name="range" href="#range">#</a> d3.<b>range</b>([<i>start</i>, ]<i>stop</i>[, <i>step</i>]) [<>](https://github.com/d3/d3-array/blob/master/src/range.js "Source")

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 [ticks](#ticks) for nicely-rounded values.)
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.)

If *step* is omitted, it defaults to 1. If *start* is omitted, it defaults to 0. The *stop* value is exclusive; it is not included in the result. If *step* is positive, the last element is the largest *start* + *i* \* *step* less than *stop*; if *step* is negative, the last element is the smallest *start* + *i* \* *step* greater than *stop*. If the returned array would contain an infinite number of values, an empty range is returned.

Expand All @@ -286,7 +290,7 @@ The arguments are not required to be integers; however, the results are more pre
d3.range(0, 1, 0.2) // [0, 0.2, 0.4, 0.6000000000000001, 0.8]
```

This unexpected behavior is due to IEEE 754 double-precision floating point, which defines 0.2 * 3 = 0.6000000000000001. Use [d3-format](https://github.com/d3/d3-format) to format numbers for human consumption with appropriate rounding; see also [linear.tickFormat](https://github.com/d3/d3-scale#linear_tickFormat) in [d3-scale](https://github.com/d3/d3-scale).
This unexpected behavior is due to IEEE 754 double-precision floating point, which defines 0.2 * 3 = 0.6000000000000001. Use [d3-format](https://github.com/d3/d3-format) to format numbers for human consumption with appropriate rounding; see also [linear.tickFormat](https://github.com/d3/d3-scale/blob/master/README.md#linear_tickFormat) in [d3-scale](https://github.com/d3/d3-scale).

Likewise, if the returned array should have a specific length, consider using [array.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) on an integer range. For example:

Expand Down Expand Up @@ -336,7 +340,7 @@ This is similar to mapping your data to values before invoking the histogram gen

If *domain* is specified, sets the domain accessor to the specified function or array and returns this histogram generator. If *domain* is not specified, returns the current domain accessor, which defaults to [extent](#extent). The histogram domain is defined as an array [*min*, *max*], where *min* is the minimum observable value and *max* is the maximum observable value; both values are inclusive. Any value outside of this domain will be ignored when the histogram is [generated](#_histogram).

For example, if you are using the the histogram in conjunction with a [linear scale](https://github.com/d3/d3-scale#linear-scales) `x`, you might say:
For example, if you are using the the histogram in conjunction with a [linear scale](https://github.com/d3/d3-scale/blob/master/README.md#linear-scales) `x`, you might say:

```js
var histogram = d3.histogram()
Expand Down
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export {default as range} from "./src/range";
export {default as scan} from "./src/scan";
export {default as shuffle} from "./src/shuffle";
export {default as sum} from "./src/sum";
export {default as ticks, tickStep} from "./src/ticks";
export {default as ticks, tickIncrement, tickStep} from "./src/ticks";
export {default as transpose} from "./src/transpose";
export {default as variance} from "./src/variance";
export {default as zip} from "./src/zip";
42 changes: 34 additions & 8 deletions src/ticks.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
import range from "./range";

var e10 = Math.sqrt(50),
e5 = Math.sqrt(10),
e2 = Math.sqrt(2);

export default function(start, stop, count) {
var step = tickStep(start, stop, count);
return range(
Math.ceil(start / step) * step,
Math.floor(stop / step) * step + step / 2, // inclusive
step
);
var reverse = stop < start,
i = -1,
n,
ticks,
step;

if (reverse) n = start, start = stop, stop = n;

if ((step = tickIncrement(start, stop, count)) === 0 || !isFinite(step)) return [];

if (step > 0) {
start = Math.ceil(start / step);
stop = Math.floor(stop / step);
ticks = new Array(n = Math.ceil(stop - start + 1));
while (++i < n) ticks[i] = (start + i) * step;
} else {
start = Math.floor(start * step);
stop = Math.ceil(stop * step);
ticks = new Array(n = Math.ceil(start - stop + 1));
while (++i < n) ticks[i] = (start - i) / step;
}

if (reverse) ticks.reverse();

return ticks;
}

export function tickIncrement(start, stop, count) {
var step = (stop - start) / Math.max(0, count),
power = Math.floor(Math.log(step) / Math.LN10),
error = step / Math.pow(10, power);
return power >= 0
? (error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1) * Math.pow(10, power)
: -Math.pow(10, -power) / (error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1);
}

export function tickStep(start, stop, count) {
Expand Down
124 changes: 60 additions & 64 deletions test/ticks-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,73 +34,69 @@ tape("ticks(start, stop, count) returns the empty array if count is infinity", f
});

tape("ticks(start, stop, count) returns approximately count + 1 ticks when start < stop", function(test) {
test.deepEqual(array.ticks( 0, 1, 10).map(round), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 9).map(round), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 8).map(round), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 7).map(round), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 6).map(round), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 5).map(round), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 4).map(round), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 3).map(round), [0.0, 0.5, 1.0]);
test.deepEqual(array.ticks( 0, 1, 2).map(round), [0.0, 0.5, 1.0]);
test.deepEqual(array.ticks( 0, 1, 1).map(round), [0.0, 1.0]);
test.deepEqual(array.ticks( 0, 10, 10).map(round), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 9).map(round), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 8).map(round), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 7).map(round), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 6).map(round), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 5).map(round), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 4).map(round), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 3).map(round), [0, 5, 10]);
test.deepEqual(array.ticks( 0, 10, 2).map(round), [0, 5, 10]);
test.deepEqual(array.ticks( 0, 10, 1).map(round), [0, 10]);
test.deepEqual(array.ticks(-10, 10, 10).map(round), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 9).map(round), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 8).map(round), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 7).map(round), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 6).map(round), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 5).map(round), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 4).map(round), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 3).map(round), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 2).map(round), [-10, 0, 10]);
test.deepEqual(array.ticks(-10, 10, 1).map(round), [ 0, ]);
test.deepEqual(array.ticks( 0, 1, 10), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 9), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 8), [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]);
test.deepEqual(array.ticks( 0, 1, 7), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 6), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 5), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 4), [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
test.deepEqual(array.ticks( 0, 1, 3), [0.0, 0.5, 1.0]);
test.deepEqual(array.ticks( 0, 1, 2), [0.0, 0.5, 1.0]);
test.deepEqual(array.ticks( 0, 1, 1), [0.0, 1.0]);
test.deepEqual(array.ticks( 0, 10, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 9), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 8), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
test.deepEqual(array.ticks( 0, 10, 7), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 6), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 5), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 4), [0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks( 0, 10, 3), [0, 5, 10]);
test.deepEqual(array.ticks( 0, 10, 2), [0, 5, 10]);
test.deepEqual(array.ticks( 0, 10, 1), [0, 10]);
test.deepEqual(array.ticks(-10, 10, 10), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 9), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 8), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 7), [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]);
test.deepEqual(array.ticks(-10, 10, 6), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 5), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 4), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 3), [-10, -5, 0, 5, 10]);
test.deepEqual(array.ticks(-10, 10, 2), [-10, 0, 10]);
test.deepEqual(array.ticks(-10, 10, 1), [ 0, ]);
test.end();
});

tape("ticks(start, stop, count) returns the reverse of ticks(stop, start, count)", function(test) {
test.deepEqual(array.ticks( 1, 0, 10).map(round), array.ticks( 0, 1, 10).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 9).map(round), array.ticks( 0, 1, 9).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 8).map(round), array.ticks( 0, 1, 8).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 7).map(round), array.ticks( 0, 1, 7).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 6).map(round), array.ticks( 0, 1, 6).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 5).map(round), array.ticks( 0, 1, 5).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 4).map(round), array.ticks( 0, 1, 4).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 3).map(round), array.ticks( 0, 1, 3).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 2).map(round), array.ticks( 0, 1, 2).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 1).map(round), array.ticks( 0, 1, 1).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 10).map(round), array.ticks( 0, 10, 10).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 9).map(round), array.ticks( 0, 10, 9).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 8).map(round), array.ticks( 0, 10, 8).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 7).map(round), array.ticks( 0, 10, 7).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 6).map(round), array.ticks( 0, 10, 6).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 5).map(round), array.ticks( 0, 10, 5).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 4).map(round), array.ticks( 0, 10, 4).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 3).map(round), array.ticks( 0, 10, 3).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 2).map(round), array.ticks( 0, 10, 2).reverse().map(round));
test.deepEqual(array.ticks(10, 0, 1).map(round), array.ticks( 0, 10, 1).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 10).map(round), array.ticks(-10, 10, 10).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 9).map(round), array.ticks(-10, 10, 9).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 8).map(round), array.ticks(-10, 10, 8).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 7).map(round), array.ticks(-10, 10, 7).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 6).map(round), array.ticks(-10, 10, 6).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 5).map(round), array.ticks(-10, 10, 5).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 4).map(round), array.ticks(-10, 10, 4).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 3).map(round), array.ticks(-10, 10, 3).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 2).map(round), array.ticks(-10, 10, 2).reverse().map(round));
test.deepEqual(array.ticks(10, -10, 1).map(round), array.ticks(-10, 10, 1).reverse().map(round));
test.deepEqual(array.ticks( 1, 0, 10), array.ticks( 0, 1, 10).reverse());
test.deepEqual(array.ticks( 1, 0, 9), array.ticks( 0, 1, 9).reverse());
test.deepEqual(array.ticks( 1, 0, 8), array.ticks( 0, 1, 8).reverse());
test.deepEqual(array.ticks( 1, 0, 7), array.ticks( 0, 1, 7).reverse());
test.deepEqual(array.ticks( 1, 0, 6), array.ticks( 0, 1, 6).reverse());
test.deepEqual(array.ticks( 1, 0, 5), array.ticks( 0, 1, 5).reverse());
test.deepEqual(array.ticks( 1, 0, 4), array.ticks( 0, 1, 4).reverse());
test.deepEqual(array.ticks( 1, 0, 3), array.ticks( 0, 1, 3).reverse());
test.deepEqual(array.ticks( 1, 0, 2), array.ticks( 0, 1, 2).reverse());
test.deepEqual(array.ticks( 1, 0, 1), array.ticks( 0, 1, 1).reverse());
test.deepEqual(array.ticks(10, 0, 10), array.ticks( 0, 10, 10).reverse());
test.deepEqual(array.ticks(10, 0, 9), array.ticks( 0, 10, 9).reverse());
test.deepEqual(array.ticks(10, 0, 8), array.ticks( 0, 10, 8).reverse());
test.deepEqual(array.ticks(10, 0, 7), array.ticks( 0, 10, 7).reverse());
test.deepEqual(array.ticks(10, 0, 6), array.ticks( 0, 10, 6).reverse());
test.deepEqual(array.ticks(10, 0, 5), array.ticks( 0, 10, 5).reverse());
test.deepEqual(array.ticks(10, 0, 4), array.ticks( 0, 10, 4).reverse());
test.deepEqual(array.ticks(10, 0, 3), array.ticks( 0, 10, 3).reverse());
test.deepEqual(array.ticks(10, 0, 2), array.ticks( 0, 10, 2).reverse());
test.deepEqual(array.ticks(10, 0, 1), array.ticks( 0, 10, 1).reverse());
test.deepEqual(array.ticks(10, -10, 10), array.ticks(-10, 10, 10).reverse());
test.deepEqual(array.ticks(10, -10, 9), array.ticks(-10, 10, 9).reverse());
test.deepEqual(array.ticks(10, -10, 8), array.ticks(-10, 10, 8).reverse());
test.deepEqual(array.ticks(10, -10, 7), array.ticks(-10, 10, 7).reverse());
test.deepEqual(array.ticks(10, -10, 6), array.ticks(-10, 10, 6).reverse());
test.deepEqual(array.ticks(10, -10, 5), array.ticks(-10, 10, 5).reverse());
test.deepEqual(array.ticks(10, -10, 4), array.ticks(-10, 10, 4).reverse());
test.deepEqual(array.ticks(10, -10, 3), array.ticks(-10, 10, 3).reverse());
test.deepEqual(array.ticks(10, -10, 2), array.ticks(-10, 10, 2).reverse());
test.deepEqual(array.ticks(10, -10, 1), array.ticks(-10, 10, 1).reverse());
test.end();
});

function round(x) {
return Math.round(x * 1e12) / 1e12;
}

0 comments on commit 04cf783

Please sign in to comment.