Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/features/legends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions docs/features/plots.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,50 @@ By default, [plot](#plot) returns an SVG element; however, if the plot includes

The **title** & **subtitle** options <VersionBadge version="0.6.10" pr="1761" /> 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({
Expand Down
3 changes: 3 additions & 0 deletions src/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface Context {
*/
document: Document;

/** The plot locale, if specified. */
locale?: string;

/** The current owner SVG element. */
ownerSVGElement: SVGSVGElement;

Expand Down
4 changes: 2 additions & 2 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {
Expand Down
8 changes: 8 additions & 0 deletions src/legends.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
24 changes: 15 additions & 9 deletions src/legends/ramp.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion src/legends/swatches.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 14 additions & 11 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
);
}
Expand Down Expand Up @@ -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);
}
);
}
Expand Down Expand Up @@ -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)}];
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
8 changes: 6 additions & 2 deletions src/marks/tip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/plot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down
23 changes: 23 additions & 0 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand Down
Loading