From 7f3daf252076ac4a6be8f0a91ec25fee7a04dd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 23 Mar 2026 15:51:54 +0100 Subject: [PATCH 1/3] format integers as numbers when all the data values are integers between 1000 and 3000 --- docs/features/scales.md | 2 + docs/marks/tip.md | 2 + src/format.js | 24 +++++++ src/marks/axis.js | 4 +- src/marks/tip.js | 13 +++- src/scales.js | 9 +++ test/marks/format-test.js | 36 ++++++++++ test/output/autoLineFacet.svg | 132 +++++++++++++++++----------------- test/output/yearFormat.svg | 126 ++++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/year-format.ts | 17 +++++ 11 files changed, 297 insertions(+), 69 deletions(-) create mode 100644 test/output/yearFormat.svg create mode 100644 test/plots/year-format.ts diff --git a/docs/features/scales.md b/docs/features/scales.md index f40894570c..96476c5726 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -177,6 +177,8 @@ Plot.plot({x: {type: "log", domain: [1e0, 1e5], grid: true}}) If you prefer conventional notation, you can specify the **tickFormat** option to change the behavior of the axis. The **tickFormat** option can either be a [d3.format](https://d3js.org/d3-format) string or a function that takes a tick value and returns the corresponding string. Note, however, that this may result in overlapping text. +For linear and ordinal scales, if the data values are all integers between 1,000 and 3,000, Plot assumes they represent years and formats the scale’s ticks without thousands separators — for example, `2026` instead of `2,026`. This also applies to the [tip mark](../marks/tip.md). + :::plot https://observablehq.com/@observablehq/plot-continuous-scales ```js Plot.plot({x: {type: "log", domain: [1e0, 1e5], tickFormat: ",", grid: true}}) diff --git a/docs/marks/tip.md b/docs/marks/tip.md index ccb4481624..3492b8345b 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -133,6 +133,8 @@ Plot.rectY(olympians, Plot.binX({y: "sum"}, {x: "weight", y: (d) => d.sex === "m The order and formatting of channels in the tip can be customized with the **format** option , which accepts a key-value object mapping channel names to formats. Each [format](../features/formats.md) can be a string (for number or time formats), a function that receives the value as input and returns a string, true to use the default format, and null or false to suppress. The order of channels in the tip follows their order in the format object followed by any additional channels. When using the **title** channel, the **format** option may be specified as a string or a function; the given format will then apply to the **title** channel. +If the data values are all integers between 1,000 and 3,000, Plot assumes they represent years and formats them without thousands separators — for example, `2026` instead of `2,026` (this heuristic doesn’t apply if the data is associated to a non-linear scale). + A channel’s label can be specified alongside its value as a {value, label} object; if a channel label is not specified, the associated scale’s label is used, if any; if there is no associated scale, or if the scale has no label, the channel name is used instead. :::plot defer https://observablehq.com/@observablehq/plot-tip-format diff --git a/src/format.js b/src/format.js index f65fb16901..a96723eee7 100644 --- a/src/format.js +++ b/src/format.js @@ -42,3 +42,27 @@ export function formatAuto(locale = "en-US") { // because it lacks context to know which locale to use; formatAuto should be // used instead whenever possible. export const formatDefault = formatAuto(); + +// Formats a number as a plain integer (no thousands separator); otherwise falls +// back to the locale-aware number format. +export function formatYear(value) { + return typeof value === "number" && isFinite(value) && value >= 0 && value <= 9999 && value % 1 === 0 + ? `${value}` + : Number.isNaN(value) + ? "NaN" + : formatNumber()(value); +} + +// Returns true if all finite values in the given domain are integers in +// [1000, 3000], indicating they might represent years. +export function isYearDomain(domain) { + let one = false; + for (const d of domain) { + if (d == null) continue; + if (typeof d !== "number") return false; + if (!isFinite(d)) continue; + if (d < 1000 || d > 3000 || d % 1 !== 0) return false; + one = true; + } + return one; +} diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..d30b1305e7 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,5 +1,5 @@ import {InternSet, extent, format, utcFormat} from "d3"; -import {formatDefault} from "../format.js"; +import {formatDefault, formatYear} from "../format.js"; import {marks} from "../mark.js"; import {radians} from "../math.js"; import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.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 && scale.year + ? formatYear : 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 bfb9d04cb2..0c51e29f11 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -2,7 +2,7 @@ import {select, format as numberFormat, utcFormat} from "d3"; import {getSource} from "../channel.js"; import {create} from "../context.js"; import {defined} from "../defined.js"; -import {formatDefault} from "../format.js"; +import {formatDefault, formatYear, isYearDomain} from "../format.js"; import {anchorX, anchorY} from "../interactions/pointer.js"; import {Mark} from "../mark.js"; import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js"; @@ -369,7 +369,16 @@ function getSourceChannels(channels, scales) { // 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; + const value = sources[key]?.value; + this.format[key] = scale + ? scale.year + ? formatYear + : scale.bandwidth + ? inferTickFormat(scale, scale.domain()) + : formatDefault + : value && isYearDomain(value) + ? formatYear + : formatDefault; } } diff --git a/src/scales.js b/src/scales.js index 7d01163ad0..c87537d622 100644 --- a/src/scales.js +++ b/src/scales.js @@ -9,6 +9,7 @@ import { coerceNumbers, coerceDates } from "./options.js"; +import {isYearDomain} from "./format.js"; import {orderof} from "./order.js"; import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js"; import { @@ -83,6 +84,13 @@ export function createScales( if (transform == null) transform = undefined; else if (typeof transform !== "function") throw new Error("invalid scale transform; not a function"); scale.percent = !!percent; + if (scale.type === "linear" || scale.bandwidth) { + if ( + channels.some(({value}) => value !== undefined) && + channels.every(({value}) => value === undefined || isYearDomain(value)) + ) + scale.year = true; + } scale.label = label === undefined ? inferScaleLabel(channels, scale) : label; scale.transform = transform; if (key === "x" || key === "fx") { @@ -109,6 +117,7 @@ export function createScaleFunctions(descriptors) { scale.type = type; if (interval != null) scale.interval = interval; if (label != null) scale.label = label; + if (descriptor.year) scale.year = true; } return scaleFunctions; } diff --git a/test/marks/format-test.js b/test/marks/format-test.js index a46754d1a4..29596fb14d 100644 --- a/test/marks/format-test.js +++ b/test/marks/format-test.js @@ -1,4 +1,5 @@ import * as Plot from "@observablehq/plot"; +import {formatYear, isYearDomain} from "../../src/format.js"; import assert from "assert"; it("formatNumber(locale) does the right thing", () => { @@ -83,3 +84,38 @@ it("formatWeekday() handles undefined input", () => { assert.strictEqual(Plot.formatWeekday()(Infinity), undefined); assert.strictEqual(Plot.formatWeekday()(1e32), undefined); }); + +it("formatYear formats year-like integers without commas", () => { + assert.strictEqual(formatYear(2000), "2000"); + assert.strictEqual(formatYear(2020), "2020"); + assert.strictEqual(formatYear(0), "0"); + assert.strictEqual(formatYear(9999), "9999"); +}); + +it("formatYear falls back to formatNumber for non-year values", () => { + assert.strictEqual(formatYear(10000), "10,000"); + assert.strictEqual(formatYear(-1), "-1"); + assert.strictEqual(formatYear(2000.5), "2,000.5"); + assert.strictEqual(formatYear(NaN), "NaN"); + assert.strictEqual(formatYear(Infinity), "∞"); +}); + +it("isYearDomain returns true for year-like integer domains", () => { + assert.strictEqual(isYearDomain([2000, 2020]), true); + assert.strictEqual(isYearDomain([1000, 3000]), true); + assert.strictEqual(isYearDomain([1990, null, 2020]), true); + assert.strictEqual(isYearDomain([1990, NaN, 2020]), true); +}); + +it("isYearDomain returns false for non-year domains", () => { + assert.strictEqual(isYearDomain([0, 100]), false); + assert.strictEqual(isYearDomain([999, 2000]), false); + assert.strictEqual(isYearDomain([2000, 3001]), false); + assert.strictEqual(isYearDomain([10000, 20000]), false); + assert.strictEqual(isYearDomain([-1, 100]), false); + assert.strictEqual(isYearDomain([2000.5, 2001.5]), false); + assert.strictEqual(isYearDomain(["a", "b"]), false); + assert.strictEqual(isYearDomain(["2000", 2020]), false); + assert.strictEqual(isYearDomain([]), false); + assert.strictEqual(isYearDomain([null, undefined]), false); +}); diff --git a/test/output/autoLineFacet.svg b/test/output/autoLineFacet.svg index e8a2315700..e41aa606db 100644 --- a/test/output/autoLineFacet.svg +++ b/test/output/autoLineFacet.svg @@ -62,118 +62,118 @@ - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 - 1,000 - 2,000 + 1,000 + 2,000 @@ -182,21 +182,21 @@ 2000 - 2002 - 2004 - 2006 - 2008 - 2010 + 2002 + 2004 + 2006 + 2008 + 2010 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 3c019e678f..fa637dd134 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -356,5 +356,6 @@ export * from "./word-length-moby-dick.js"; export * from "./yearly-requests-dot.js"; export * from "./yearly-requests-line.js"; export * from "./yearly-requests.js"; +export * from "./year-format.js"; export * from "./young-adults.js"; export * from "./zero.js"; diff --git a/test/plots/year-format.ts b/test/plots/year-format.ts new file mode 100644 index 0000000000..9c34d7a324 --- /dev/null +++ b/test/plots/year-format.ts @@ -0,0 +1,17 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export 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 9d1806d6e7c626c6e36f5e9aecb056796ca5929d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 24 Mar 2026 14:01:53 +0100 Subject: [PATCH 2/3] more consistency when formatYear is applied to non-integers --- src/format.js | 12 ++++++++---- test/marks/format-test.js | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/format.js b/src/format.js index a96723eee7..099d02b4be 100644 --- a/src/format.js +++ b/src/format.js @@ -6,6 +6,10 @@ const numberFormat = memoize1((locale) => { return new Intl.NumberFormat(locale); }); +const yearFormat = memoize1((locale) => { + return new Intl.NumberFormat(locale, {useGrouping: false}); +}); + const monthFormat = memoize1((locale, month) => { return new Intl.DateTimeFormat(locale, {timeZone: "UTC", ...(month && {month})}); }); @@ -43,11 +47,11 @@ export function formatAuto(locale = "en-US") { // used instead whenever possible. export const formatDefault = formatAuto(); -// Formats a number as a plain integer (no thousands separator); otherwise falls -// back to the locale-aware number format. +// Formats a number without thousands separator; falls back to the +// locale-aware number format for values outside [0, 10000). export function formatYear(value) { - return typeof value === "number" && isFinite(value) && value >= 0 && value <= 9999 && value % 1 === 0 - ? `${value}` + return typeof value === "number" && value >= 0 && value < 10000 + ? yearFormat("en-US").format(value) : Number.isNaN(value) ? "NaN" : formatNumber()(value); diff --git a/test/marks/format-test.js b/test/marks/format-test.js index 29596fb14d..2a98aef2de 100644 --- a/test/marks/format-test.js +++ b/test/marks/format-test.js @@ -85,17 +85,18 @@ it("formatWeekday() handles undefined input", () => { assert.strictEqual(Plot.formatWeekday()(1e32), undefined); }); -it("formatYear formats year-like integers without commas", () => { +it("formatYear formats numbers in [0, 10000) without commas", () => { assert.strictEqual(formatYear(2000), "2000"); assert.strictEqual(formatYear(2020), "2020"); assert.strictEqual(formatYear(0), "0"); assert.strictEqual(formatYear(9999), "9999"); + assert.strictEqual(formatYear(2023.56), "2023.56"); + assert.strictEqual(formatYear(2023.5678901234568), "2023.568"); }); -it("formatYear falls back to formatNumber for non-year values", () => { +it("formatYear falls back to formatNumber for other values", () => { assert.strictEqual(formatYear(10000), "10,000"); assert.strictEqual(formatYear(-1), "-1"); - assert.strictEqual(formatYear(2000.5), "2,000.5"); assert.strictEqual(formatYear(NaN), "NaN"); assert.strictEqual(formatYear(Infinity), "∞"); }); From 76430ff0ed93f7b482808213176b880d9ebe9f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 24 Mar 2026 14:07:59 +0100 Subject: [PATCH 3/3] excess precision does no good --- test/marks/format-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/marks/format-test.js b/test/marks/format-test.js index 2a98aef2de..5c9163411c 100644 --- a/test/marks/format-test.js +++ b/test/marks/format-test.js @@ -91,7 +91,7 @@ it("formatYear formats numbers in [0, 10000) without commas", () => { assert.strictEqual(formatYear(0), "0"); assert.strictEqual(formatYear(9999), "9999"); assert.strictEqual(formatYear(2023.56), "2023.56"); - assert.strictEqual(formatYear(2023.5678901234568), "2023.568"); + assert.strictEqual(formatYear(2023.5678901234), "2023.568"); }); it("formatYear falls back to formatNumber for other values", () => {