Skip to content

Commit

Permalink
document multi-line tick format; improve format for custom ticks (#1728)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
Fil and mbostock committed Aug 21, 2023
1 parent e48647e commit 9735f6f
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 32 deletions.
20 changes: 4 additions & 16 deletions docs/marks/axis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/legends/swatches.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 9 additions & 9 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
}
Expand Down Expand Up @@ -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);
}
);
}
Expand Down Expand Up @@ -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)}];
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/time.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -110,15 +110,15 @@ 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"
? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
: 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":
Expand All @@ -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];
}
Expand Down
100 changes: 100 additions & 0 deletions test/output/timeAxisExplicitInterval.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions test/plots/time-axis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {svg} from "htl";

const domains = [
Expand Down Expand Up @@ -74,3 +75,10 @@ export async function timeAxisRight() {
})}`
)}`;
}

export async function timeAxisExplicitInterval() {
const aapl = await d3.csv<any>("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"})]
});
}

0 comments on commit 9735f6f

Please sign in to comment.