From a4363a0f9212d68a5feacd125ce01abe3db0fd2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 27 Jun 2023 11:44:40 +0200 Subject: [PATCH 1/3] document multi-line tick format (ref: #1718, #1725) --- docs/marks/axis.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/docs/marks/axis.md b/docs/marks/axis.md index 0f9411640c..daa434320b 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({ + x: {tickSpacing: 60}, marks: [ Plot.ruleY([0]), Plot.line(aapl, {x: "Date", y: "Close"}), 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")) - }) ] }) ``` ::: -:::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 From 750513c2afdc56f79b38b7ca2157986728d44c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 27 Jun 2023 17:27:31 +0200 Subject: [PATCH 2/3] Update docs/marks/axis.md Co-authored-by: Mike Bostock --- docs/marks/axis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/marks/axis.md b/docs/marks/axis.md index daa434320b..ab6cc514fc 100644 --- a/docs/marks/axis.md +++ b/docs/marks/axis.md @@ -158,7 +158,7 @@ Plot.plot({ ``` ::: -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. +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 From e039fe1a44e0fe612a5ece2171ef0f4814858d94 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 27 Jun 2023 09:40:12 -0700 Subject: [PATCH 3/3] better formats for explicit intervals --- docs/marks/axis.md | 4 +- src/legends/swatches.js | 2 +- src/marks/axis.js | 18 ++-- src/time.js | 18 ++-- test/output/timeAxisExplicitInterval.svg | 100 +++++++++++++++++++++++ test/plots/time-axis.ts | 8 ++ 6 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 test/output/timeAxisExplicitInterval.svg diff --git a/docs/marks/axis.md b/docs/marks/axis.md index ab6cc514fc..0807fc5d00 100644 --- a/docs/marks/axis.md +++ b/docs/marks/axis.md @@ -148,11 +148,11 @@ Time axes default to a consistent multi-line tick format, [à la Datawrapper](ht :::plot https://observablehq.com/@observablehq/plot-datawrapper-style-date-axis ```js Plot.plot({ - x: {tickSpacing: 60}, marks: [ Plot.ruleY([0]), - Plot.line(aapl, {x: "Date", y: "Close"}), + Plot.axisX({ticks: "3 months"}), Plot.gridX(), + Plot.line(aapl, {x: "Date", y: "Close"}) ] }) ``` 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 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jul2013 + Oct + Jan2014 + Apr + Jul + Oct + Jan2015 + Apr + Jul + Oct + Jan2016 + Apr + Jul + Oct + Jan2017 + Apr + Jul + Oct + Jan2018 + Apr + + + + + + + + + + + + \ 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"})] + }); +}