Skip to content

Commit 9735f6f

Browse files
Filmbostock
andcommitted
document multi-line tick format; improve format for custom ticks (#1728)
* document multi-line tick format (ref: #1718, #1725) * Update docs/marks/axis.md Co-authored-by: Mike Bostock <[email protected]> * better formats for explicit intervals --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent e48647e commit 9735f6f

File tree

6 files changed

+134
-32
lines changed

6 files changed

+134
-32
lines changed

docs/marks/axis.md

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -143,34 +143,22 @@ Plot.plot({
143143
```
144144
:::
145145

146-
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.
146+
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:
147147

148148
:::plot https://observablehq.com/@observablehq/plot-datawrapper-style-date-axis
149149
```js
150150
Plot.plot({
151151
marks: [
152152
Plot.ruleY([0]),
153-
Plot.line(aapl, {x: "Date", y: "Close"}),
153+
Plot.axisX({ticks: "3 months"}),
154154
Plot.gridX(),
155-
Plot.axisX({
156-
ticks: 20,
157-
tickFormat: (
158-
(formatYear, formatMonth) => (x) =>
159-
x.getUTCMonth() === 0
160-
? `${formatMonth(x)}\n${formatYear(x)}`
161-
: formatMonth(x)
162-
)(d3.utcFormat("%Y"), d3.utcFormat("%b"))
163-
})
155+
Plot.line(aapl, {x: "Date", y: "Close"})
164156
]
165157
})
166158
```
167159
:::
168160

169-
:::tip
170-
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).
171-
:::
172-
173-
Alternatively, you can add multiple axes with options for hierarchical time intervals, here showing weeks, months, and years.
161+
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.
174162

175163
:::plot https://observablehq.com/@observablehq/plot-multiscale-date-axis
176164
```js

src/legends/swatches.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function legendItems(scale, options = {}, swatch) {
8686
} = options;
8787
const context = createContext(options);
8888
className = maybeClassName(className);
89-
if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, undefined, tickFormat);
89+
if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);
9090

9191
const swatches = create("div", context).attr(
9292
"class",

src/marks/axis.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,9 @@ function axisTextKy(
366366
...options,
367367
dx: anchor === "left" ? +dx - tickSize - tickPadding + +insetLeft : +dx + +tickSize + +tickPadding - insetRight
368368
},
369-
function (scale, ticks, channels) {
369+
function (scale, data, ticks, channels) {
370370
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
371-
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
371+
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
372372
}
373373
);
374374
}
@@ -413,9 +413,9 @@ function axisTextKx(
413413
...options,
414414
dy: anchor === "bottom" ? +dy + +tickSize + +tickPadding - insetBottom : +dy - tickSize - tickPadding + +insetTop
415415
},
416-
function (scale, ticks, channels) {
416+
function (scale, data, ticks, channels) {
417417
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
418-
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
418+
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
419419
}
420420
);
421421
}
@@ -545,7 +545,7 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
545545
channels[k] = {scale: k, value: identity};
546546
}
547547
}
548-
initialize?.call(this, scale, ticks, channels);
548+
initialize?.call(this, scale, data, ticks, channels);
549549
const initializedChannels = Object.fromEntries(
550550
Object.entries(channels).map(([name, channel]) => {
551551
return [name, {...channel, value: valueof(data, channel.value)}];
@@ -565,16 +565,16 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
565565
return m;
566566
}
567567

568-
function inferTextChannel(scale, ticks, tickFormat, anchor) {
569-
return {value: inferTickFormat(scale, ticks, tickFormat, anchor)};
568+
function inferTextChannel(scale, data, ticks, tickFormat, anchor) {
569+
return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)};
570570
}
571571

572572
// D3’s ordinal scales simply use toString by default, but if the ordinal scale
573573
// domain (or ticks) are numbers or dates (say because we’re applying a time
574574
// interval to the ordinal scale), we want Plot’s default formatter.
575-
export function inferTickFormat(scale, ticks, tickFormat, anchor) {
575+
export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
576576
return tickFormat === undefined && isTemporalScale(scale)
577-
? formatTimeTicks(scale, ticks, anchor)
577+
? formatTimeTicks(scale, data, ticks, anchor)
578578
: scale.tickFormat
579579
? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat)
580580
: tickFormat === undefined

src/time.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {bisector, extent, timeFormat, utcFormat} from "d3";
1+
import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3";
22
import {utcSecond, utcMinute, utcHour, unixDay, utcWeek, utcMonth, utcYear} from "d3";
33
import {utcMonday, utcTuesday, utcWednesday, utcThursday, utcFriday, utcSaturday, utcSunday} from "d3";
44
import {timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear} from "d3";
@@ -110,15 +110,15 @@ export function isTimeYear(i) {
110110
return timeYear(date) >= date; // coercing equality
111111
}
112112

113-
export function formatTimeTicks(scale, ticks, anchor) {
113+
export function formatTimeTicks(scale, data, ticks, anchor) {
114114
const format = scale.type === "time" ? timeFormat : utcFormat;
115115
const template =
116116
anchor === "left" || anchor === "right"
117117
? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
118118
: anchor === "top"
119119
? (f1, f2) => `${f2}\n${f1}`
120120
: (f1, f2) => `${f1}\n${f2}`;
121-
switch (getTimeTicksInterval(scale, ticks)) {
121+
switch (getTimeTicksInterval(scale, data, ticks)) {
122122
case "millisecond":
123123
return formatConditional(format(".%L"), format(":%M:%S"), template);
124124
case "second":
@@ -139,10 +139,16 @@ export function formatTimeTicks(scale, ticks, anchor) {
139139
throw new Error("unable to format time ticks");
140140
}
141141

142-
// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L43-L50
143-
function getTimeTicksInterval(scale, ticks) {
142+
// Compute the median difference between adjacent ticks, ignoring repeated
143+
// ticks; this implies an effective time interval, assuming that ticks are
144+
// regularly spaced; choose the largest format less than this interval so that
145+
// the ticks show the field that is changing. If the ticks are not available,
146+
// fallback to an approximation based on the desired number of ticks.
147+
function getTimeTicksInterval(scale, data, ticks) {
148+
const medianStep = median(pairs(data, (a, b) => Math.abs(b - a) || NaN));
149+
if (medianStep > 0) return formats[bisector(([, step]) => step).right(formats, medianStep, 1, formats.length) - 1][0];
144150
const [start, stop] = extent(scale.domain());
145-
const count = typeof ticks === "number" ? ticks : 10; // TODO detect ticks as time interval?
151+
const count = typeof ticks === "number" ? ticks : 10;
146152
const step = Math.abs(stop - start) / count;
147153
return formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0];
148154
}

test/output/timeAxisExplicitInterval.svg

Lines changed: 100 additions & 0 deletions
Loading

test/plots/time-axis.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
23
import {svg} from "htl";
34

45
const domains = [
@@ -74,3 +75,10 @@ export async function timeAxisRight() {
7475
})}`
7576
)}`;
7677
}
78+
79+
export async function timeAxisExplicitInterval() {
80+
const aapl = await d3.csv<any>("data/aapl.csv", d3.autoType);
81+
return Plot.plot({
82+
marks: [Plot.ruleY([0]), Plot.axisX({ticks: "3 months"}), Plot.gridX(), Plot.line(aapl, {x: "Date", y: "Close"})]
83+
});
84+
}

0 commit comments

Comments
 (0)