From b16e7057dcca1d2eacaa771b17c785da34fd501a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 1 Apr 2026 09:55:04 -0700 Subject: [PATCH 1/5] isYearIntegers --- src/marks/axis.js | 7 ++- src/options.js | 8 +++ test/output/yearFormat.svg | 126 +++++++++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/year-format.ts | 18 ++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 test/output/yearFormat.svg create mode 100644 test/plots/year-format.ts diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..7ed39f522a 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"; @@ -671,7 +671,10 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { : tickFormat === undefined && data && isTemporal(data) ? inferTimeFormat(scale.type, data, anchor) ?? formatDefault : scale.tickFormat - ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) + ? scale.tickFormat( + typeof ticks === "number" ? ticks : null, + tickFormat === undefined && data && isYearIntegers(data) ? "d" : tickFormat + ) : typeof tickFormat === "string" && scale.domain().length > 0 ? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat) : tickFormat === undefined 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/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/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..dca9a8e9b0 --- /dev/null +++ b/test/plots/year-format.ts @@ -0,0 +1,18 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {test} from "test/plot"; + +test(async function yearFormat() { + const raw = await d3.csv("data/bls-industry-unemployment.csv", d3.autoType); + const data = d3 + .rollups( + raw, + (v) => d3.median(v, (d) => d.unemployed), + (d) => d.date.getUTCFullYear(), + (d) => d.industry + ) + .flatMap(([year, industries]) => industries.map(([industry, unemployed]) => ({year, industry, unemployed}))); + return Plot.plot({ + marks: [Plot.line(data, {x: "year", y: "unemployed", stroke: "industry", marker: true, tip: true}), Plot.ruleY([0])] + }); +}); From 1cb1ee07347a014b925ce7c5f61de0a066cc9d75 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 1 Apr 2026 09:57:36 -0700 Subject: [PATCH 2/5] lineY; inline raw --- test/plots/year-format.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/plots/year-format.ts b/test/plots/year-format.ts index dca9a8e9b0..efbc125c34 100644 --- a/test/plots/year-format.ts +++ b/test/plots/year-format.ts @@ -3,16 +3,18 @@ import * as d3 from "d3"; import {test} from "test/plot"; test(async function yearFormat() { - const raw = await d3.csv("data/bls-industry-unemployment.csv", d3.autoType); const data = d3 .rollups( - raw, - (v) => d3.median(v, (d) => d.unemployed), + 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}))); return Plot.plot({ - marks: [Plot.line(data, {x: "year", y: "unemployed", stroke: "industry", marker: true, tip: true}), Plot.ruleY([0])] + marks: [ + Plot.lineY(data, {x: "year", y: "unemployed", stroke: "industry", marker: true, tip: true}), + Plot.ruleY([0]) + ] }); }); From 5c6d4c078057ccfc4ead88af42db1221c1a9a906 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 1 Apr 2026 12:35:51 -0700 Subject: [PATCH 3/5] year heuristic for ordinal scale, tip, too --- src/marks/axis.js | 7 +- src/marks/tip.js | 15 +- test/output/yearFormatOrdinal.svg | 228 ++++++++++++++++++++++++++++++ test/plots/year-format.ts | 18 ++- 4 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 test/output/yearFormatOrdinal.svg diff --git a/src/marks/axis.js b/src/marks/axis.js index 7ed39f522a..07e458e60c 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -670,11 +670,10 @@ 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 === undefined && data && isYearIntegers(data) ? "d" : tickFormat - ) + ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : typeof tickFormat === "string" && scale.domain().length > 0 ? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat) : tickFormat === undefined 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/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/year-format.ts b/test/plots/year-format.ts index efbc125c34..dee75bea4f 100644 --- a/test/plots/year-format.ts +++ b/test/plots/year-format.ts @@ -2,8 +2,8 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {test} from "test/plot"; -test(async function yearFormat() { - const data = d3 +async function getYearlyUnemployment() { + return d3 .rollups( await d3.csv("data/bls-industry-unemployment.csv", d3.autoType), (D) => d3.median(D, (d) => d.unemployed), @@ -11,6 +11,10 @@ test(async function yearFormat() { (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}), @@ -18,3 +22,13 @@ test(async function yearFormat() { ] }); }); + +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]) + ] + }); +}); From 631fa6ffa33f7909e1c3e5b5d8467e3a7c0ccba7 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 1 Apr 2026 12:45:25 -0700 Subject: [PATCH 4/5] isYearInteger tests --- test/options-test.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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); From 095857b5dc02402c58f3ddee5800c2f5c8b1799f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 1 Apr 2026 12:55:12 -0700 Subject: [PATCH 5/5] document year heuristic --- docs/features/scales.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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/).