Skip to content

Commit 7ab67cb

Browse files
authored
isYearIntegers (#2403)
* isYearIntegers * lineY; inline raw * year heuristic for ordinal scale, tip, too * isYearInteger tests * document year heuristic
1 parent e31a7ae commit 7ab67cb

9 files changed

Lines changed: 455 additions & 6 deletions

File tree

docs/features/scales.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ Plot.plot({x: {type: "time", domain: [new Date(2021, 0, 1), new Date(2022, 0, 1)
167167
```
168168
:::
169169

170+
If the tick values are all integers between 1,500 and 2,500 (inclusive), Plot assumes the values represent years and formats ticks by default without thousand separators, such as 1996 instead of 1,996. You can explicitly suppress thousand separators with `tickFormat: "d"`, or enable them with `tickFormat: ",d"`. <VersionBadge pr="2403" />
171+
172+
:::plot
173+
```js
174+
Plot.plot({x: {domain: [1992, 2003], grid: true}})
175+
```
176+
:::
177+
170178
When plotting values that vary widely, such as the luminosity of stars in an [HR diagram](https://observablehq.com/@mbostock/hertzsprung-russell-diagram), a *log* scale may improve readability. Log scales default to base-10 ticks with SI-prefix notation.
171179

172180
:::plot https://observablehq.com/@observablehq/plot-continuous-scales
@@ -294,6 +302,14 @@ Plot.plot({
294302

295303
Position scales also have a **round** option which forces the scale to snap to integer pixels. This defaults to true for point and band scales, and false for quantitative scales. Use caution with high-cardinality ordinal domains (*i.e.*, a point or band scale used to encode many different values), as rounding can lead to “wasted” space or even zero-width bands.
296304

305+
If the domain values are all integers between 1,500 and 2,500 (inclusive), Plot assumes the values represent years and formats ticks by default without thousand separators, such as 1996 instead of 1,996. You can explicitly suppress thousand separators with `tickFormat: "d"`, or enable them with `tickFormat: ",d"`. <VersionBadge pr="2403" />
306+
307+
:::plot
308+
```js
309+
Plot.plot({x: {type: "band", domain: d3.range(1992, 2003), grid: true}})
310+
```
311+
:::
312+
297313
## Color scales
298314

299315
While position is the most salient (and important) encoding, color matters too! The default quantitative color scale **type** is *linear*, and the default **scheme** is [*turbo*](https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html). A wide variety of sequential, diverging, and cyclical schemes are supported, including ColorBrewer and [*viridis*](http://bids.github.io/colormap/).

src/marks/axis.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {formatDefault} from "../format.js";
33
import {marks} from "../mark.js";
44
import {radians} from "../math.js";
55
import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js";
6-
import {isIterable, isNoneish, isTemporal, isInterval} from "../options.js";
6+
import {isIterable, isNoneish, isYearIntegers, isTemporal, isInterval} from "../options.js";
77
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
88
import {inferScaleOrder} from "../scales.js";
99
import {offset} from "../style.js";
@@ -670,6 +670,8 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
670670
? tickFormat
671671
: tickFormat === undefined && data && isTemporal(data)
672672
? inferTimeFormat(scale.type, data, anchor) ?? formatDefault
673+
: tickFormat === undefined && data && isYearIntegers(data)
674+
? String
673675
: scale.tickFormat
674676
? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat)
675677
: typeof tickFormat === "string" && scale.domain().length > 0

src/marks/tip.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js";
77
import {Mark} from "../mark.js";
88
import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js";
99
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js";
10-
import {identity, isIterable, isTemporal, isTextual} from "../options.js";
10+
import {identity, isIterable, isTemporal, isTextual, isYearIntegers} from "../options.js";
1111
import {inferTickFormat} from "./axis.js";
1212
import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js";
1313
import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
@@ -362,14 +362,19 @@ function getSourceChannels(channels, scales) {
362362
// Promote shorthand string formats, and materialize default formats.
363363
for (const key in sources) {
364364
const format = this.format[key];
365+
const scale = scales[key];
366+
const value = sources[key]?.value ?? scale?.domain() ?? [];
365367
if (typeof format === "string") {
366-
const value = sources[key]?.value ?? scales[key]?.domain() ?? [];
367368
this.format[key] = (isTemporal(value) ? utcFormat : numberFormat)(format);
368369
} else if (format === undefined || format === true) {
369370
// For ordinal scales, the inferred tick format can be more concise, such
370-
// as only showing the year for yearly data.
371-
const scale = scales[key];
372-
this.format[key] = scale?.bandwidth ? inferTickFormat(scale, scale.domain()) : formatDefault;
371+
// as only showing the year for yearly data. Similarly if all the values
372+
// look like years, we can avoid the thousands comma.
373+
this.format[key] = scale?.bandwidth
374+
? inferTickFormat(scale, scale.domain())
375+
: isYearIntegers(value)
376+
? String
377+
: formatDefault;
373378
}
374379
}
375380

src/options.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,14 @@ export function isNumeric(values) {
513513
}
514514
}
515515

516+
export function isYearIntegers(values) {
517+
return isEvery(values, isYearInteger);
518+
}
519+
520+
export function isYearInteger(value) {
521+
return typeof value === "number" && Number.isInteger(value) && 1500 <= value && value <= 2500;
522+
}
523+
516524
// Returns true if every non-null value in the specified iterable of values
517525
// passes the specified predicate, and there is at least one non-null value;
518526
// returns false if at least one non-null value does not pass the specified

test/options-test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
import {assert, it} from "vitest";
22
import {identity, isNumericString, valueof} from "../src/options.js";
3+
import {isYearInteger, isYearIntegers} from "../src/options.js";
4+
5+
it("isYearInteger returns true for integers in [1500, 2500]", () => {
6+
assert.strictEqual(isYearInteger(1500), true);
7+
assert.strictEqual(isYearInteger(1999), true);
8+
assert.strictEqual(isYearInteger(2000), true);
9+
assert.strictEqual(isYearInteger(2001), true);
10+
assert.strictEqual(isYearInteger(2500), true);
11+
});
12+
13+
it("isYearInteger returns false for non-integers, or numbers outside [1500, 2500]", () => {
14+
assert.strictEqual(isYearInteger(-2000), false);
15+
assert.strictEqual(isYearInteger(-1500), false);
16+
assert.strictEqual(isYearInteger(0), false);
17+
assert.strictEqual(isYearInteger("1000"), false);
18+
assert.strictEqual(isYearInteger(null), false);
19+
assert.strictEqual(isYearInteger(2000.5), false);
20+
assert.strictEqual(isYearInteger(NaN), false);
21+
assert.strictEqual(isYearInteger(undefined), false);
22+
});
23+
24+
it("isYearIntegers requires every value to be a year integer", () => {
25+
assert.strictEqual(isYearIntegers([]), undefined);
26+
assert.strictEqual(isYearIntegers([1]), false);
27+
assert.strictEqual(isYearIntegers([2000]), true);
28+
assert.strictEqual(isYearIntegers([2000, 1]), false);
29+
assert.strictEqual(isYearIntegers([2000, "2000"]), false);
30+
assert.strictEqual(isYearIntegers([2000, 1999]), true);
31+
});
332

433
it("isNumericString detects numeric strings", () => {
534
assert.strictEqual(isNumericString(["42"]), true);

test/output/yearFormat.svg

Lines changed: 126 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)