From a5d0848f81f5b0ece3a4226742999f33d377c864 Mon Sep 17 00:00:00 2001 From: Yusuf Khasbulatov Date: Wed, 25 Mar 2026 23:05:41 +0100 Subject: [PATCH] Add locale and lang/dir support for localized output --- docs/features/legends.md | 3 ++ docs/features/plots.md | 44 +++++++++++++++++ src/context.d.ts | 3 ++ src/context.js | 4 +- src/legends.d.ts | 8 ++++ src/legends/ramp.js | 24 ++++++---- src/legends/swatches.js | 2 +- src/marks/axis.js | 25 +++++----- src/marks/tip.js | 8 +++- src/plot.d.ts | 21 ++++++++ src/plot.js | 23 +++++++++ src/time.js | 100 +++++++++++++++++++++++++++++++++++++-- test/document-test.js | 27 +++++++++++ test/legend-test.js | 9 ++++ test/plot-test.ts | 26 ++++++++++ 15 files changed, 298 insertions(+), 29 deletions(-) diff --git a/docs/features/legends.md b/docs/features/legends.md index b1e39f06da..e8f7d305c9 100644 --- a/docs/features/legends.md +++ b/docs/features/legends.md @@ -106,6 +106,7 @@ The **fill** and **stroke** symbol legend options can be specified as “color Continuous color legends are rendered as a ramp, and can be configured with the following options: * **label** - the scale’s label +* **locale** - a locale used for default tick formatting * **ticks** - the desired number of ticks, or an array of tick values * **tickFormat** - a format function for the legend’s ticks * **tickSize** - the tick size @@ -120,6 +121,8 @@ Continuous color legends are rendered as a ramp, and can be configured with the The **style** legend option allows custom styles to override Plot’s defaults; it has the same behavior as in Plot’s top-level [plot options](./plots.md). The **className** option is suffixed with *-ramp* or *-swatches*, reflecting the **legend** type. +If **locale** is specified, Plot uses it for the legend’s default numeric and temporal labels. You can still override formatting explicitly with **tickFormat**. + ## legend(*options*) {#legend} Renders a standalone legend for the scale defined by the given *options* object, returning a SVG or HTML figure element. This element can then be inserted into the page as described in the [getting started guide](../getting-started.md). The *options* object must define at least one scale; see [scale options](./scales.md) for how to define a scale. diff --git a/docs/features/plots.md b/docs/features/plots.md index 0902dadda9..5932817c62 100644 --- a/docs/features/plots.md +++ b/docs/features/plots.md @@ -258,6 +258,50 @@ By default, [plot](#plot) returns an SVG element; however, if the plot includes The **title** & **subtitle** options and the **caption** option accept either a string or an HTML element. If given an HTML element, say using the [`html` tagged template literal](http://github.com/observablehq/htl), the title and subtitle are used as-is while the caption is wrapped in a figcaption element; otherwise, the specified text will be escaped and wrapped in an h2, h3, or figcaption, respectively. +## Localization + +Plot supports a top-level **locale** option for default locale-sensitive formatting of axes, legends, and tips. + +:::plot +```js +Plot.plot({ + locale: "fr-FR", + x: {type: "utc", domain: [new Date("2023-01-01"), new Date("2024-01-01")]}, + marks: [ + Plot.lineY( + [ + {date: new Date("2023-01-01"), value: 12345.67}, + {date: new Date("2023-07-01"), value: 23456.78}, + {date: new Date("2024-01-01"), value: 34567.89} + ], + {x: "date", y: "value", tip: true} + ) + ] +}) +``` +::: + +When **locale** is specified, Plot’s default formatters use that locale for: + +* numeric axis ticks +* time axis ticks +* legend labels +* default tip values + +User-provided strings such as **title**, **subtitle**, **caption**, **ariaLabel**, and scale **label** are not translated automatically; pass those in the desired language from your application. + +The **lang** option sets the language of the generated plot element, while **dir** sets text direction. If **lang** is omitted, it defaults to the language subtag of **locale**. If **dir** is omitted or set to **auto**, Plot infers left-to-right or right-to-left direction from **lang**. + +```js +Plot.plot({ + locale: "ar-SA", + lang: "ar", + dir: "rtl", + title: "الإيرادات الشهرية", + marks: [...] +}) +``` + :::plot https://observablehq.com/@observablehq/plot-caption ```js Plot.plot({ diff --git a/src/context.d.ts b/src/context.d.ts index 53a1c01fee..c20792d289 100644 --- a/src/context.d.ts +++ b/src/context.d.ts @@ -9,6 +9,9 @@ export interface Context { */ document: Document; + /** The plot locale, if specified. */ + locale?: string; + /** The current owner SVG element. */ ownerSVGElement: SVGSVGElement; diff --git a/src/context.js b/src/context.js index 3e3e55d705..9d96caf31f 100644 --- a/src/context.js +++ b/src/context.js @@ -2,8 +2,8 @@ import {creator, select} from "d3"; import {maybeClip} from "./options.js"; export function createContext(options = {}) { - const {document = typeof window !== "undefined" ? window.document : undefined, clip} = options; - return {document, clip: maybeClip(clip)}; + const {document = typeof window !== "undefined" ? window.document : undefined, clip, locale} = options; + return {document, clip: maybeClip(clip), locale}; } export function create(name, {document}) { diff --git a/src/legends.d.ts b/src/legends.d.ts index c07d6f3761..fef500659d 100644 --- a/src/legends.d.ts +++ b/src/legends.d.ts @@ -84,6 +84,14 @@ export interface SymbolLegendOptions extends SwatchesLegendOptions { /** Options for generating a scale legend. */ export interface LegendOptions extends ColorLegendOptions, SymbolLegendOptions, OpacityLegendOptions { + /** + * A [BCP 47 language tag][1] used for the legend’s default locale-sensitive + * formatting of numbers and dates. Defaults to U.S. English. + * + * [1]: https://tools.ietf.org/html/bcp47 + */ + locale?: string; + /** * The desired legend type; one of: * diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 9b11b65c70..b667adca87 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,6 +1,7 @@ import {quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3"; import {inferFontVariant} from "../axes.js"; import {createContext, create} from "../context.js"; +import {formatAuto} from "../format.js"; import {map, maybeNumberChannel} from "../options.js"; import {interpolatePiecewise} from "../scales/quantitative.js"; import {applyInlineStyles, impliedString, maybeClassName, offset} from "../style.js"; @@ -24,6 +25,7 @@ export function legendRamp(color, options) { className } = options; const context = createContext(options); + const defaultTickFormat = context.locale === undefined ? undefined : formatAuto(context.locale); className = maybeClassName(className); opacity = maybeNumberChannel(opacity)[1]; if (tickFormat === null) tickFormat = () => null; @@ -112,7 +114,11 @@ export function legendRamp(color, options) { const thresholds = domain; const thresholdFormat = - tickFormat === undefined ? (d) => d : typeof tickFormat === "string" ? format(tickFormat) : tickFormat; + tickFormat === undefined + ? defaultTickFormat ?? ((d) => d) + : typeof tickFormat === "string" + ? format(tickFormat) + : tickFormat; // Construct a linear scale with evenly-spaced ticks for each of the // thresholds; the domain extends one beyond the threshold extent. @@ -155,17 +161,17 @@ export function legendRamp(color, options) { tickAdjust = () => {}; } + const axis = axisBottom(x) + .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined) + .tickFormat(typeof tickFormat === "function" ? tickFormat : defaultTickFormat) + .tickSize(tickSize) + .tickValues(Array.isArray(ticks) ? ticks : null) + .offset(offset); + svg .append("g") .attr("transform", `translate(0,${height - marginBottom})`) - .call( - axisBottom(x) - .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined) - .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined) - .tickSize(tickSize) - .tickValues(Array.isArray(ticks) ? ticks : null) - .offset(offset) - ) + .call(axis) .attr("font-size", null) .attr("font-family", null) .attr("font-variant", impliedString(fontVariant, "normal")) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 9c05717b43..3959f29fb9 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -99,7 +99,7 @@ function legendItems(scale, options = {}, swatch) { } = options; const context = createContext(options); className = maybeClassName(className); - tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat); + tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat, undefined, context.locale); const swatches = create("div", context).attr( "class", diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..6d0ded047d 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 {formatAuto, 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"; @@ -393,9 +393,9 @@ function axisTextKy( ...options, dx: anchor === "left" ? +dx - tickSize - tickPadding + +insetLeft : +dx + +tickSize + +tickPadding - insetRight }, - function (scale, data, ticks, tickFormat, channels) { + function (scale, data, ticks, tickFormat, channels, context) { if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale); - if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor); + if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor, context.locale); } ); } @@ -440,9 +440,9 @@ function axisTextKx( ...options, dy: anchor === "bottom" ? +dy + +tickSize + +tickPadding - insetBottom : +dy - tickSize - tickPadding + +insetTop }, - function (scale, data, ticks, tickFormat, channels) { + function (scale, data, ticks, tickFormat, channels, context) { if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale); - if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor); + if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor, context.locale); } ); } @@ -626,7 +626,7 @@ function axisMark(mark, k, data, properties, options, initialize) { channels[k] = {scale: k, value: identity}; } } - initialize?.call(this, scale, data, ticks, tickFormat, channels); + initialize?.call(this, scale, data, ticks, tickFormat, channels, context); const initializedChannels = Object.fromEntries( Object.entries(channels).map(([name, channel]) => { return [name, {...channel, value: valueof(data, channel.value)}]; @@ -655,8 +655,8 @@ function inferTickCount(scale, tickSpacing) { return (max - min) / tickSpacing; } -function inferTextChannel(scale, data, ticks, tickFormat, anchor) { - return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)}; +function inferTextChannel(scale, data, ticks, tickFormat, anchor, locale) { + return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor, locale)}; } // D3’s ordinal scales simply use toString by default, but if the ordinal scale @@ -665,17 +665,20 @@ function inferTextChannel(scale, data, ticks, tickFormat, anchor) { // time ticks, we want to use the multi-line time format (e.g., Jan 26) if // possible, or the default ISO format (2014-01-26). TODO We need a better way // to infer whether the ordinal scale is UTC or local time. -export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { +export function inferTickFormat(scale, data, ticks, tickFormat, anchor, locale) { + const fallback = locale === undefined ? formatDefault : formatAuto(locale); return typeof tickFormat === "function" && !(scale.type === "log" && scale.tickFormat) ? tickFormat : tickFormat === undefined && data && isTemporal(data) - ? inferTimeFormat(scale.type, data, anchor) ?? formatDefault + ? inferTimeFormat(scale.type, data, anchor, locale) ?? fallback + : tickFormat === undefined && locale !== undefined + ? fallback : scale.tickFormat ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : typeof tickFormat === "string" && scale.domain().length > 0 ? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat) : tickFormat === undefined - ? formatDefault + ? fallback : constant(tickFormat); } diff --git a/src/marks/tip.js b/src/marks/tip.js index bfb9d04cb2..69a132fc60 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 {formatAuto} from "../format.js"; import {anchorX, anchorY} from "../interactions/pointer.js"; import {Mark} from "../mark.js"; import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js"; @@ -88,6 +88,7 @@ export class Tip extends Mark { } render(index, scales, values, dimensions, context) { const mark = this; + mark.locale = context.locale; const {x, y, fx, fy} = scales; const {ownerSVGElement: svg, document} = context; const {anchor, monospace, lineHeight, lineWidth} = this; @@ -369,7 +370,10 @@ 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; + this.format[key] = + scale?.bandwidth + ? inferTickFormat(scale, scale.domain(), undefined, undefined, undefined, this.locale) + : formatAuto(this.locale); } } diff --git a/src/plot.d.ts b/src/plot.d.ts index 8cec69b03f..ad2a17c275 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -186,6 +186,27 @@ export interface PlotOptions extends ScaleDefaults { */ document?: Document; + /** + * A [BCP 47 language tag][1] used for Plot’s default locale-sensitive + * formatting of numbers and dates in axes, legends, and tips. Defaults to + * U.S. English. + * + * [1]: https://tools.ietf.org/html/bcp47 + */ + locale?: string; + + /** + * The language of the generated plot content. Defaults to the language + * subtag of **locale**, if specified. + */ + lang?: string; + + /** + * The text direction of the generated plot content. If **auto**, derives + * direction from **lang**. + */ + dir?: "ltr" | "rtl" | "auto"; + /** The default clip for all marks. */ clip?: MarkOptions["clip"]; diff --git a/src/plot.js b/src/plot.js index 16976c2585..f468b70e9d 100644 --- a/src/plot.js +++ b/src/plot.js @@ -340,6 +340,8 @@ export function plot(options = {}) { if ("value" in svg) (figure.value = svg.value), delete svg.value; } + applyLanguageAttributes(figured ? figure : svg, options); + figure.scale = exposeScales(scales.scales); figure.legend = exposeLegends(scaleDescriptors, context, options); @@ -360,6 +362,27 @@ export function plot(options = {}) { return figure; } +const rtlLanguages = new Set(["ar", "fa", "he", "ps", "sd", "ug", "ur", "yi", "ku", "ckb"]); + +function resolveLang({lang, locale}) { + return lang ?? locale?.split("-")[0]; +} + +function resolveDir({dir, lang, locale}) { + if (dir === "ltr" || dir === "rtl") return dir; + const resolvedLang = resolveLang({lang, locale}); + if (dir === "auto" || resolvedLang !== undefined) return resolvedLang && rtlLanguages.has(resolvedLang) ? "rtl" : "ltr"; +} + +function applyLanguageAttributes(element, options) { + const lang = resolveLang(options); + const dir = resolveDir(options); + if (lang === undefined) element.removeAttribute("lang"); + else element.setAttribute("lang", lang); + if (dir === undefined) element.removeAttribute("dir"); + else element.setAttribute("dir", dir); +} + function createTitleElement(document, contents, tag) { if (contents.ownerDocument) return contents; const e = document.createElement(tag); diff --git a/src/time.js b/src/time.js index 118c99daad..88274b88cb 100644 --- a/src/time.js +++ b/src/time.js @@ -212,7 +212,8 @@ export function generalizeTimeInterval(interval, n) { return (interval[intervalType] === "time" ? timeInterval : utcInterval)(i); } -function formatTimeInterval(name, type, anchor) { +function formatTimeInterval(name, type, anchor, locale) { + if (locale !== undefined) return formatLocaleTimeInterval(name, type, anchor, locale); const format = type === "time" ? timeFormat : utcFormat; // For tips and legends, use a format that doesn’t require context. if (anchor == null) { @@ -251,6 +252,97 @@ function formatTimeInterval(name, type, anchor) { throw new Error("unable to format time ticks"); } +function formatLocaleTimeInterval(name, type, anchor, locale) { + const timeZone = type === "utc" ? "UTC" : undefined; + const format = (options) => new Intl.DateTimeFormat(locale, {timeZone, ...options}); + + // For tips and legends, prefer a single localized label. + if (anchor == null) { + switch (name) { + case "millisecond": + return (d) => + format({ + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3 + }).format(d); + case "second": + return (d) => + format({ + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit" + }).format(d); + case "minute": + case "hour": + return (d) => + format({ + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit" + }).format(d); + case "day": + return (d) => format({year: "numeric", month: "short", day: "numeric"}).format(d); + case "month": + return (d) => format({year: "numeric", month: "short"}).format(d); + case "year": + return (d) => format({year: "numeric"}).format(d); + } + } + + const template = getTimeTemplate(anchor); + switch (name) { + case "millisecond": + return formatConditional( + (d) => format({hour: "numeric", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3}).format(d), + (d) => format({month: "short", day: "numeric"}).format(d), + template + ); + case "second": + return formatConditional( + (d) => format({hour: "numeric", minute: "2-digit", second: "2-digit"}).format(d), + (d) => format({month: "short", day: "numeric"}).format(d), + template + ); + case "minute": + return formatConditional( + (d) => format({hour: "numeric", minute: "2-digit"}).format(d), + (d) => format({month: "short", day: "numeric"}).format(d), + template + ); + case "hour": + return formatConditional( + (d) => format({hour: "numeric"}).format(d), + (d) => format({month: "short", day: "numeric"}).format(d), + template + ); + case "day": + return formatConditional( + (d) => format({day: "numeric"}).format(d), + (d) => format({month: "short"}).format(d), + template + ); + case "month": + return formatConditional( + (d) => format({month: "short"}).format(d), + (d) => format({year: "numeric"}).format(d), + template + ); + case "year": + return (d) => format({year: "numeric"}).format(d); + } + throw new Error("unable to format localized time ticks"); +} + function getTimeTemplate(anchor) { return anchor === "left" || anchor === "right" ? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered @@ -266,13 +358,13 @@ function getFormatIntervals(type) { // Given an array of dates, returns the largest compatible standard time // interval. If no standard interval is compatible (other than milliseconds, // which is universally compatible), returns undefined. -export function inferTimeFormat(type, dates, anchor) { +export function inferTimeFormat(type, dates, anchor, locale) { const step = max(pairs(dates, (a, b) => Math.abs(b - a))); // maybe undefined! - if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor); + if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor, locale); for (const [name, interval, intervalType, maxStep] of getFormatIntervals(type)) { if (step > maxStep) break; // e.g., 52 weeks if (name === "hour" && !step) break; // e.g., domain with a single date - if (dates.every((d) => interval.floor(d) >= d)) return formatTimeInterval(name, intervalType, anchor); + if (dates.every((d) => interval.floor(d) >= d)) return formatTimeInterval(name, intervalType, anchor, locale); } } diff --git a/test/document-test.js b/test/document-test.js index 0624ddc575..e9c2901430 100644 --- a/test/document-test.js +++ b/test/document-test.js @@ -58,3 +58,30 @@ it("plot.legend supports the document option for categorical color scales", () = }).legend("color"); assert.strictEqual(svg.ownerDocument, window.document); }); + +it("Plot.plot derives lang and dir from locale on svg output", () => { + const {window} = new JSDOM(""); + const svg = Plot.plot({document: window.document, locale: "ar-SA", marks: [Plot.barY([1, 2, 4, 3])]}); + assert.strictEqual(svg.getAttribute("lang"), "ar"); + assert.strictEqual(svg.getAttribute("dir"), "rtl"); +}); + +it("Plot.plot applies explicit lang and dir on figure output", () => { + const {window} = new JSDOM(""); + const figure = Plot.plot({ + document: window.document, + figure: true, + lang: "ar", + dir: "rtl", + marks: [Plot.barY([1, 2, 4, 3])] + }); + assert.strictEqual(figure.tagName, "FIGURE"); + assert.strictEqual(figure.getAttribute("lang"), "ar"); + assert.strictEqual(figure.getAttribute("dir"), "rtl"); +}); + +it("Plot.plot resolves dir:auto from lang", () => { + const {window} = new JSDOM(""); + const svg = Plot.plot({document: window.document, lang: "ar", dir: "auto", marks: [Plot.barY([1, 2, 4, 3])]}); + assert.strictEqual(svg.getAttribute("dir"), "rtl"); +}); diff --git a/test/legend-test.js b/test/legend-test.js index f66aa45c2a..38b03b3c77 100644 --- a/test/legend-test.js +++ b/test/legend-test.js @@ -23,3 +23,12 @@ it("Plot.legend({}) throws an error", () => { it("Plot.legend({color: {}}) throws an error", () => { assert.throws(() => Plot.legend({color: {}}), /unknown legend type/); }); + +it("Plot.legend({... locale}) localizes swatch labels", () => { + const legend = Plot.legend({ + locale: "fr", + color: {type: "ordinal", domain: [12345], range: ["red"]}, + legend: "swatches" + }); + assert.ok(legend.textContent.includes("12\u202f345")); +}); diff --git a/test/plot-test.ts b/test/plot-test.ts index baedd583ae..d37e642174 100644 --- a/test/plot-test.ts +++ b/test/plot-test.ts @@ -6,3 +6,29 @@ it("plot({aspectRatio}) rejects unsupported scale types", () => { assert.throws(() => Plot.dot([]).plot({aspectRatio: true, x: {type: "symlog"}}), /^Error: unsupported x scale for aspectRatio: symlog$/); // prettier-ignore assert.throws(() => Plot.dot([]).plot({aspectRatio: true, y: {type: "symlog"}}), /^Error: unsupported y scale for aspectRatio: symlog$/); // prettier-ignore }); + +it("plot({locale}) localizes default axis tick formatting", () => { + const plot = Plot.plot({ + locale: "fr", + x: {type: "linear"}, + marks: [Plot.axisX([12345])] + }); + assert.ok(plot.querySelector("text")?.textContent?.includes("12\u202f345")); +}); + +it("plot({locale}) localizes default tip formatting", () => { + const plot = Plot.plot({ + locale: "fr", + marks: [Plot.tip([{x: 12345, y: 1}], {x: "x", y: "y"})] + }); + assert.ok(plot.textContent?.includes("12\u202f345")); +}); + +it("plot({locale}) localizes default time axis formatting", () => { + const plot = Plot.plot({ + locale: "fr", + x: {type: "utc", domain: [new Date("2023-01-01"), new Date("2024-01-01")]} + }); + assert.ok(plot.textContent?.includes("janv.")); + assert.ok(!plot.textContent?.includes("Jan")); +});