diff --git a/docs/marks/axis.md b/docs/marks/axis.md
index 0f9411640c..0807fc5d00 100644
--- a/docs/marks/axis.md
+++ b/docs/marks/axis.md
@@ -143,34 +143,22 @@ Plot.plot({
```
:::
-You can emulate [Datawrapper’s time axes](https://blog.datawrapper.de/new-axis-ticks/) using `\n` (the line feed character) for multi-line tick labels, plus a bit of date math to detect the first month of each year.
+Time axes default to a consistent multi-line tick format, [à la Datawrapper](https://blog.datawrapper.de/new-axis-ticks/), for example showing the first month of each quarter, and the year:
:::plot https://observablehq.com/@observablehq/plot-datawrapper-style-date-axis
```js
Plot.plot({
marks: [
Plot.ruleY([0]),
- Plot.line(aapl, {x: "Date", y: "Close"}),
+ Plot.axisX({ticks: "3 months"}),
Plot.gridX(),
- Plot.axisX({
- ticks: 20,
- tickFormat: (
- (formatYear, formatMonth) => (x) =>
- x.getUTCMonth() === 0
- ? `${formatMonth(x)}\n${formatYear(x)}`
- : formatMonth(x)
- )(d3.utcFormat("%Y"), d3.utcFormat("%b"))
- })
+ Plot.line(aapl, {x: "Date", y: "Close"})
]
})
```
:::
-:::tip
-In the future, Plot may generate multi-line time axis labels by default. If you’re interested in this feature, please upvote [#1285](https://github.com/observablehq/plot/issues/1285).
-:::
-
-Alternatively, you can add multiple axes with options for hierarchical time intervals, here showing weeks, months, and years.
+The format is inferred from the tick interval, and consists of two fields (*e.g.*, month and year, day and month, minutes and hours); when a tick has the same second field value as the previous tick (*e.g.*, “19 Jan” after “17 Jan”), only the first field (“19”) is shown for brevity. Alternatively, you can specify multiple explicit axes with options for hierarchical time intervals, here showing weeks, months, and years.
:::plot https://observablehq.com/@observablehq/plot-multiscale-date-axis
```js
diff --git a/src/legends/swatches.js b/src/legends/swatches.js
index ac1a26e507..5685f5c9e3 100644
--- a/src/legends/swatches.js
+++ b/src/legends/swatches.js
@@ -86,7 +86,7 @@ function legendItems(scale, options = {}, swatch) {
} = options;
const context = createContext(options);
className = maybeClassName(className);
- if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, undefined, tickFormat);
+ if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);
const swatches = create("div", context).attr(
"class",
diff --git a/src/marks/axis.js b/src/marks/axis.js
index 8b96182686..44d8190277 100644
--- a/src/marks/axis.js
+++ b/src/marks/axis.js
@@ -366,9 +366,9 @@ function axisTextKy(
...options,
dx: anchor === "left" ? +dx - tickSize - tickPadding + +insetLeft : +dx + +tickSize + +tickPadding - insetRight
},
- function (scale, ticks, channels) {
+ function (scale, data, ticks, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
- if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
+ if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
}
);
}
@@ -413,9 +413,9 @@ function axisTextKx(
...options,
dy: anchor === "bottom" ? +dy + +tickSize + +tickPadding - insetBottom : +dy - tickSize - tickPadding + +insetTop
},
- function (scale, ticks, channels) {
+ function (scale, data, ticks, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
- if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
+ if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
}
);
}
@@ -545,7 +545,7 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
channels[k] = {scale: k, value: identity};
}
}
- initialize?.call(this, scale, ticks, channels);
+ initialize?.call(this, scale, data, ticks, channels);
const initializedChannels = Object.fromEntries(
Object.entries(channels).map(([name, channel]) => {
return [name, {...channel, value: valueof(data, channel.value)}];
@@ -565,16 +565,16 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
return m;
}
-function inferTextChannel(scale, ticks, tickFormat, anchor) {
- return {value: inferTickFormat(scale, ticks, tickFormat, anchor)};
+function inferTextChannel(scale, data, ticks, tickFormat, anchor) {
+ return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)};
}
// D3’s ordinal scales simply use toString by default, but if the ordinal scale
// domain (or ticks) are numbers or dates (say because we’re applying a time
// interval to the ordinal scale), we want Plot’s default formatter.
-export function inferTickFormat(scale, ticks, tickFormat, anchor) {
+export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
return tickFormat === undefined && isTemporalScale(scale)
- ? formatTimeTicks(scale, ticks, anchor)
+ ? formatTimeTicks(scale, data, ticks, anchor)
: scale.tickFormat
? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat)
: tickFormat === undefined
diff --git a/src/time.js b/src/time.js
index 3020309503..661a928184 100644
--- a/src/time.js
+++ b/src/time.js
@@ -1,4 +1,4 @@
-import {bisector, extent, timeFormat, utcFormat} from "d3";
+import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3";
import {utcSecond, utcMinute, utcHour, unixDay, utcWeek, utcMonth, utcYear} from "d3";
import {utcMonday, utcTuesday, utcWednesday, utcThursday, utcFriday, utcSaturday, utcSunday} from "d3";
import {timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear} from "d3";
@@ -110,7 +110,7 @@ export function isTimeYear(i) {
return timeYear(date) >= date; // coercing equality
}
-export function formatTimeTicks(scale, ticks, anchor) {
+export function formatTimeTicks(scale, data, ticks, anchor) {
const format = scale.type === "time" ? timeFormat : utcFormat;
const template =
anchor === "left" || anchor === "right"
@@ -118,7 +118,7 @@ export function formatTimeTicks(scale, ticks, anchor) {
: anchor === "top"
? (f1, f2) => `${f2}\n${f1}`
: (f1, f2) => `${f1}\n${f2}`;
- switch (getTimeTicksInterval(scale, ticks)) {
+ switch (getTimeTicksInterval(scale, data, ticks)) {
case "millisecond":
return formatConditional(format(".%L"), format(":%M:%S"), template);
case "second":
@@ -139,10 +139,16 @@ export function formatTimeTicks(scale, ticks, anchor) {
throw new Error("unable to format time ticks");
}
-// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L43-L50
-function getTimeTicksInterval(scale, ticks) {
+// Compute the median difference between adjacent ticks, ignoring repeated
+// ticks; this implies an effective time interval, assuming that ticks are
+// regularly spaced; choose the largest format less than this interval so that
+// the ticks show the field that is changing. If the ticks are not available,
+// fallback to an approximation based on the desired number of ticks.
+function getTimeTicksInterval(scale, data, ticks) {
+ const medianStep = median(pairs(data, (a, b) => Math.abs(b - a) || NaN));
+ if (medianStep > 0) return formats[bisector(([, step]) => step).right(formats, medianStep, 1, formats.length) - 1][0];
const [start, stop] = extent(scale.domain());
- const count = typeof ticks === "number" ? ticks : 10; // TODO detect ticks as time interval?
+ const count = typeof ticks === "number" ? ticks : 10;
const step = Math.abs(stop - start) / count;
return formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0];
}
diff --git a/test/output/timeAxisExplicitInterval.svg b/test/output/timeAxisExplicitInterval.svg
new file mode 100644
index 0000000000..ec925189ec
--- /dev/null
+++ b/test/output/timeAxisExplicitInterval.svg
@@ -0,0 +1,100 @@
+
\ No newline at end of file
diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts
index 264bda2175..dcb19f7fb0 100644
--- a/test/plots/time-axis.ts
+++ b/test/plots/time-axis.ts
@@ -1,4 +1,5 @@
import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
import {svg} from "htl";
const domains = [
@@ -74,3 +75,10 @@ export async function timeAxisRight() {
})}`
)}`;
}
+
+export async function timeAxisExplicitInterval() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ marks: [Plot.ruleY([0]), Plot.axisX({ticks: "3 months"}), Plot.gridX(), Plot.line(aapl, {x: "Date", y: "Close"})]
+ });
+}