Skip to content

Commit 167a0dc

Browse files
committed
better formats for explicit intervals
1 parent 750513c commit 167a0dc

File tree

7 files changed

+142
-28
lines changed

7 files changed

+142
-28
lines changed

docs/marks/axis.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,11 @@ Time axes default to a consistent multi-line tick format, [à la Datawrapper](ht
148148
:::plot https://observablehq.com/@observablehq/plot-datawrapper-style-date-axis
149149
```js
150150
Plot.plot({
151-
x: {tickSpacing: 60},
152151
marks: [
153152
Plot.ruleY([0]),
154-
Plot.line(aapl, {x: "Date", y: "Close"}),
153+
Plot.axisX({ticks: "3 months"}),
155154
Plot.gridX(),
155+
Plot.line(aapl, {x: "Date", y: "Close"})
156156
]
157157
})
158158
```

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/colorLegendOrdinalTickFormat.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@
2525
}
2626
</style><span class="plot-swatch"><svg width="15" height="15" fill="rgb(35, 23, 27)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2727
<rect width="100%" height="100%"></rect>
28-
</svg>1.0</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(38, 188, 225)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
28+
</svg>1</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(38, 188, 225)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2929
<rect width="100%" height="100%"></rect>
30-
</svg>2.0</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(149, 251, 81)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
30+
</svg>2</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(149, 251, 81)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3131
<rect width="100%" height="100%"></rect>
32-
</svg>3.0</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(255, 130, 29)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
32+
</svg>3</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(255, 130, 29)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3333
<rect width="100%" height="100%"></rect>
34-
</svg>4.0</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(144, 12, 0)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
34+
</svg>4</span><span class="plot-swatch"><svg width="15" height="15" fill="rgb(144, 12, 0)" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3535
<rect width="100%" height="100%"></rect>
36-
</svg>5.0</span>
36+
</svg>5</span>
3737
</div>

test/output/colorLegendQuantileSwatches.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@
2525
}
2626
</style><span class="plot-swatch"><svg width="15" height="15" fill="#320a5e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2727
<rect width="100%" height="100%"></rect>
28-
</svg>200</span><span class="plot-swatch"><svg width="15" height="15" fill="#781c6d" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
28+
</svg>200.143</span><span class="plot-swatch"><svg width="15" height="15" fill="#781c6d" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2929
<rect width="100%" height="100%"></rect>
30-
</svg>800</span><span class="plot-swatch"><svg width="15" height="15" fill="#bc3754" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
30+
</svg>800.286</span><span class="plot-swatch"><svg width="15" height="15" fill="#bc3754" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3131
<rect width="100%" height="100%"></rect>
32-
</svg>1,800</span><span class="plot-swatch"><svg width="15" height="15" fill="#ed6925" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
32+
</svg>1,800.429</span><span class="plot-swatch"><svg width="15" height="15" fill="#ed6925" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3333
<rect width="100%" height="100%"></rect>
34-
</svg>3,201</span><span class="plot-swatch"><svg width="15" height="15" fill="#fbb61a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
34+
</svg>3,200.571</span><span class="plot-swatch"><svg width="15" height="15" fill="#fbb61a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3535
<rect width="100%" height="100%"></rect>
36-
</svg>5,001</span><span class="plot-swatch"><svg width="15" height="15" fill="#fcffa4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
36+
</svg>5,000.714</span><span class="plot-swatch"><svg width="15" height="15" fill="#fcffa4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3737
<rect width="100%" height="100%"></rect>
38-
</svg>7,201</span>
38+
</svg>7,200.857</span>
3939
</div>

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)