diff --git a/docs/features/scales.md b/docs/features/scales.md index c805d0ae0e..c9a494b805 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -167,6 +167,14 @@ Plot.plot({x: {type: "time", domain: [new Date(2021, 0, 1), new Date(2022, 0, 1) ``` ::: +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"`. + +:::plot +```js +Plot.plot({x: {domain: [1992, 2003], grid: true}}) +``` +::: + 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. :::plot https://observablehq.com/@observablehq/plot-continuous-scales @@ -294,6 +302,14 @@ Plot.plot({ 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. +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"`. + +:::plot +```js +Plot.plot({x: {type: "band", domain: d3.range(1992, 2003), grid: true}}) +``` +::: + ## Color scales 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/). diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..07e458e60c 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -3,7 +3,7 @@ import {formatDefault} from "../format.js"; import {marks} from "../mark.js"; import {radians} from "../math.js"; import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js"; -import {isIterable, isNoneish, isTemporal, isInterval} from "../options.js"; +import {isIterable, isNoneish, isYearIntegers, isTemporal, isInterval} from "../options.js"; import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; import {inferScaleOrder} from "../scales.js"; import {offset} from "../style.js"; @@ -670,6 +670,8 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { ? tickFormat : tickFormat === undefined && data && isTemporal(data) ? inferTimeFormat(scale.type, data, anchor) ?? formatDefault + : tickFormat === undefined && data && isYearIntegers(data) + ? String : scale.tickFormat ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : typeof tickFormat === "string" && scale.domain().length > 0 diff --git a/src/marks/tip.js b/src/marks/tip.js index 4982c38049..7ed1b44ac7 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js"; import {Mark} from "../mark.js"; import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js"; import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js"; -import {identity, isIterable, isTemporal, isTextual} from "../options.js"; +import {identity, isIterable, isTemporal, isTextual, isYearIntegers} from "../options.js"; import {inferTickFormat} from "./axis.js"; import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js"; import {cut, clipper, splitter, maybeTextOverflow} from "./text.js"; @@ -362,14 +362,19 @@ function getSourceChannels(channels, scales) { // Promote shorthand string formats, and materialize default formats. for (const key in sources) { const format = this.format[key]; + const scale = scales[key]; + const value = sources[key]?.value ?? scale?.domain() ?? []; if (typeof format === "string") { - const value = sources[key]?.value ?? scales[key]?.domain() ?? []; this.format[key] = (isTemporal(value) ? utcFormat : numberFormat)(format); } else if (format === undefined || format === true) { // For ordinal scales, the inferred tick format can be more concise, such - // as only showing the year for yearly data. - const scale = scales[key]; - this.format[key] = scale?.bandwidth ? inferTickFormat(scale, scale.domain()) : formatDefault; + // as only showing the year for yearly data. Similarly if all the values + // look like years, we can avoid the thousands comma. + this.format[key] = scale?.bandwidth + ? inferTickFormat(scale, scale.domain()) + : isYearIntegers(value) + ? String + : formatDefault; } } diff --git a/src/options.js b/src/options.js index d11bee1bb4..0f1c93952a 100644 --- a/src/options.js +++ b/src/options.js @@ -513,6 +513,14 @@ export function isNumeric(values) { } } +export function isYearIntegers(values) { + return isEvery(values, isYearInteger); +} + +export function isYearInteger(value) { + return typeof value === "number" && Number.isInteger(value) && 1500 <= value && value <= 2500; +} + // Returns true if every non-null value in the specified iterable of values // passes the specified predicate, and there is at least one non-null value; // returns false if at least one non-null value does not pass the specified diff --git a/test/options-test.js b/test/options-test.js index 6d55683992..e2c0634f6e 100644 --- a/test/options-test.js +++ b/test/options-test.js @@ -1,5 +1,34 @@ import {assert, it} from "vitest"; import {identity, isNumericString, valueof} from "../src/options.js"; +import {isYearInteger, isYearIntegers} from "../src/options.js"; + +it("isYearInteger returns true for integers in [1500, 2500]", () => { + assert.strictEqual(isYearInteger(1500), true); + assert.strictEqual(isYearInteger(1999), true); + assert.strictEqual(isYearInteger(2000), true); + assert.strictEqual(isYearInteger(2001), true); + assert.strictEqual(isYearInteger(2500), true); +}); + +it("isYearInteger returns false for non-integers, or numbers outside [1500, 2500]", () => { + assert.strictEqual(isYearInteger(-2000), false); + assert.strictEqual(isYearInteger(-1500), false); + assert.strictEqual(isYearInteger(0), false); + assert.strictEqual(isYearInteger("1000"), false); + assert.strictEqual(isYearInteger(null), false); + assert.strictEqual(isYearInteger(2000.5), false); + assert.strictEqual(isYearInteger(NaN), false); + assert.strictEqual(isYearInteger(undefined), false); +}); + +it("isYearIntegers requires every value to be a year integer", () => { + assert.strictEqual(isYearIntegers([]), undefined); + assert.strictEqual(isYearIntegers([1]), false); + assert.strictEqual(isYearIntegers([2000]), true); + assert.strictEqual(isYearIntegers([2000, 1]), false); + assert.strictEqual(isYearIntegers([2000, "2000"]), false); + assert.strictEqual(isYearIntegers([2000, 1999]), true); +}); it("isNumericString detects numeric strings", () => { assert.strictEqual(isNumericString(["42"]), true); diff --git a/test/output/yearFormat.svg b/test/output/yearFormat.svg new file mode 100644 index 0000000000..1827d03b5b --- /dev/null +++ b/test/output/yearFormat.svg @@ -0,0 +1,126 @@ + + + + + 0 + 200 + 400 + 600 + 800 + 1,000 + 1,200 + 1,400 + 1,600 + 1,800 + 2,000 + 2,200 + + + ↑ unemployed + + + + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + 2008 + 2009 + 2010 + + + year → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/yearFormatOrdinal.svg b/test/output/yearFormatOrdinal.svg new file mode 100644 index 0000000000..4c56df7630 --- /dev/null +++ b/test/output/yearFormatOrdinal.svg @@ -0,0 +1,228 @@ + + + + + 0 + 2,000 + 4,000 + 6,000 + 8,000 + 10,000 + 12,000 + 14,000 + + + ↑ unemployed + + + + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + 2008 + 2009 + 2010 + + + year + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.ts b/test/plots/index.ts index 19bc952011..238de1f9c0 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -354,6 +354,7 @@ import "./wealth-britain-bar.js"; import "./wealth-britain-proportion-plot.js"; import "./word-cloud.js"; import "./word-length-moby-dick.js"; +import "./year-format.js"; import "./yearly-requests-dot.js"; import "./yearly-requests-line.js"; import "./yearly-requests.js"; diff --git a/test/plots/year-format.ts b/test/plots/year-format.ts new file mode 100644 index 0000000000..dee75bea4f --- /dev/null +++ b/test/plots/year-format.ts @@ -0,0 +1,34 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {test} from "test/plot"; + +async function getYearlyUnemployment() { + return d3 + .rollups( + await d3.csv("data/bls-industry-unemployment.csv", d3.autoType), + (D) => d3.median(D, (d) => d.unemployed), + (d) => d.date.getUTCFullYear(), + (d) => d.industry + ) + .flatMap(([year, industries]) => industries.map(([industry, unemployed]) => ({year, industry, unemployed}))); +} + +test(async function yearFormat() { + const data = await getYearlyUnemployment(); + return Plot.plot({ + marks: [ + Plot.lineY(data, {x: "year", y: "unemployed", stroke: "industry", marker: true, tip: true}), + Plot.ruleY([0]) + ] + }); +}); + +test(async function yearFormatOrdinal() { + const data = await getYearlyUnemployment(); + return Plot.plot({ + marks: [ + Plot.barY(data, {x: "year", y: "unemployed", fill: "industry", tip: true}), // + Plot.ruleY([0]) + ] + }); +});