Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

document multi-line tick format; improve format for custom ticks #1728

Merged
merged 3 commits into from
Jun 27, 2023
Merged
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
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"})]
});
}