diff --git a/docs/features/scales.md b/docs/features/scales.md index f40894570c..f51bb6a59a 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 defaults to a **tickFormat** without thousands separators — for example, `2026` instead of `2,026`. You can opt into this behavior explicitly with `tickFormat: "year"`. + :::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..00f765b612 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..8d10ea32d9 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})}); }); @@ -42,3 +46,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 without thousands separator; falls back to the +// locale-aware number format for values outside [0, 10000). +export function formatYear(value) { + return typeof value === "number" && value >= 0 && value < 10000 + ? yearFormat("en-US").format(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 check = 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; + check = true; + } + return check; +} diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..fe7fcaba29 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,12 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { ? tickFormat : tickFormat === undefined && data && isTemporal(data) ? inferTimeFormat(scale.type, data, anchor) ?? formatDefault + : typeof tickFormat === "string" && tickFormat.toLowerCase() === "year" + ? (data && isTemporal(data)) || (scale.domain && isTemporal(scale.domain())) + ? utcFormat("%Y") + : formatYear + : 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..f91d14fd2b 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"; @@ -362,14 +362,25 @@ function getSourceChannels(channels, scales) { // Promote shorthand string formats, and materialize default formats. for (const key in sources) { const format = this.format[key]; - if (typeof format === "string") { + if (typeof format === "string" && format.toLowerCase() === "year") { + this.format[key] = formatYear; + } else 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; + 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..902b5a0994 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 { @@ -74,6 +75,7 @@ export function createScales( label = key === "fx" || key === "fy" ? facetLabel : globalLabel, percent, transform, + tickFormat, inset, insetTop = inset !== undefined ? inset : key === "y" ? globalInsetTop : 0, // not fy insetRight = inset !== undefined ? inset : key === "x" ? globalInsetRight : 0, // not fx @@ -83,6 +85,15 @@ 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 (typeof tickFormat === "string" && tickFormat.toLowerCase() === "year") { + scale.year = true; + } else 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 +120,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..e88a708182 100644 --- a/test/marks/format-test.js +++ b/test/marks/format-test.js @@ -1,4 +1,6 @@ import * as Plot from "@observablehq/plot"; +import {formatYear, isYearDomain} from "../../src/format.js"; +import {inferTickFormat} from "../../src/marks/axis.js"; import assert from "assert"; it("formatNumber(locale) does the right thing", () => { @@ -83,3 +85,53 @@ it("formatWeekday() handles undefined input", () => { assert.strictEqual(Plot.formatWeekday()(Infinity), undefined); assert.strictEqual(Plot.formatWeekday()(1e32), undefined); }); + +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.5678901234), "2023.568"); +}); + +it("formatYear falls back to formatNumber for other values", () => { + assert.strictEqual(formatYear(10000), "10,000"); + assert.strictEqual(formatYear(-1), "-1"); + 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("tickFormat 'year' opts into year formatting", () => { + const scale = {type: "linear", domain: () => [0, 100]}; + assert.strictEqual(inferTickFormat(scale, [0, 100], null, "year"), formatYear); + assert.strictEqual(inferTickFormat(scale, [0, 100], null, "Year"), formatYear); + assert.strictEqual(inferTickFormat(scale, [0, 100], null, "YEAR"), formatYear); +}); + +it("tickFormat 'year' on temporal data uses %Y", () => { + const dates = [new Date("2020-01-01"), new Date("2025-01-01")]; + const scale = {type: "utc", domain: () => dates}; + const fmt = inferTickFormat(scale, dates, null, "year"); + assert.strictEqual(fmt(new Date("2023-06-15")), "2023"); +}); + +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/output/yearFormatExplicit.svg b/test/output/yearFormatExplicit.svg new file mode 100644 index 0000000000..fe6ff49a14 --- /dev/null +++ b/test/output/yearFormatExplicit.svg @@ -0,0 +1,80 @@ + + + + + 1.0 + 1.2 + 1.4 + 1.6 + 1.8 + 2.0 + 2.2 + 2.4 + 2.6 + 2.8 + 3.0 + + + ↑ y + + + + 4000 + 4200 + 4400 + 4600 + 4800 + 5000 + 5200 + 5400 + 5600 + 5800 + 6000 + + + x → + + + + + + + + \ 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..5cc38fbc42 --- /dev/null +++ b/test/plots/year-format.ts @@ -0,0 +1,33 @@ +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])] + }); +} + +export async function yearFormatExplicit() { + return Plot.plot({ + x: {tickFormat: "year"}, + marks: [ + Plot.dot( + [ + {x: 4000, y: 1}, + {x: 5000, y: 2}, + {x: 6000, y: 3} + ], + {x: "x", y: "y", tip: true} + ) + ] + }); +}