Skip to content

Commit 11d50fd

Browse files
committed
ordinal time axis
1 parent 6adee12 commit 11d50fd

19 files changed

+707
-298
lines changed

src/marks/axis.js

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {extent, format, timeFormat, utcFormat} from "d3";
1+
import {extent, format, median, pairs, timeFormat, utcFormat} from "d3";
22
import {formatDefault} from "../format.js";
33
import {marks} from "../mark.js";
44
import {radians} from "../math.js";
55
import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js";
6-
import {isIterable, isNoneish, isTemporal, orderof} from "../options.js";
6+
import {isIterable, isNoneish, isTemporal, isTimeInterval, orderof} from "../options.js";
77
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
8-
import {isTemporalScale} from "../scales.js";
8+
import {isOrdinalScale, isTemporalScale} from "../scales.js";
99
import {offset} from "../style.js";
10-
import {formatTimeTicks, isTimeYear, isUtcYear} from "../time.js";
10+
import {formatTimeInterval, formatTimeTicks, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js";
1111
import {initializer} from "../transforms/basic.js";
1212
import {ruleX, ruleY} from "./rule.js";
1313
import {text, textX, textY} from "./text.js";
@@ -277,7 +277,7 @@ function axisTickKy(
277277
...options
278278
}
279279
) {
280-
return axisMark(vectorY, k, `${k}-axis tick`, data, {
280+
return axisMark(vectorY, k, anchor, `${k}-axis tick`, data, {
281281
strokeWidth,
282282
strokeLinecap,
283283
strokeLinejoin,
@@ -311,7 +311,7 @@ function axisTickKx(
311311
...options
312312
}
313313
) {
314-
return axisMark(vectorX, k, `${k}-axis tick`, data, {
314+
return axisMark(vectorX, k, anchor, `${k}-axis tick`, data, {
315315
strokeWidth,
316316
strokeLinejoin,
317317
strokeLinecap,
@@ -336,8 +336,7 @@ function axisTextKy(
336336
tickSize,
337337
tickRotate = 0,
338338
tickPadding = Math.max(3, 9 - tickSize) + (Math.abs(tickRotate) > 60 ? 4 * Math.cos(tickRotate * radians) : 0),
339-
tickFormat,
340-
text = typeof tickFormat === "function" ? tickFormat : undefined,
339+
text,
341340
textAnchor = Math.abs(tickRotate) > 60 ? "middle" : anchor === "left" ? "end" : "start",
342341
lineAnchor = tickRotate > 60 ? "top" : tickRotate < -60 ? "bottom" : "middle",
343342
fontVariant,
@@ -352,12 +351,13 @@ function axisTextKy(
352351
return axisMark(
353352
textY,
354353
k,
354+
anchor,
355355
`${k}-axis tick label`,
356356
data,
357357
{
358358
facetAnchor,
359359
frameAnchor,
360-
text: text === undefined ? null : text,
360+
text,
361361
textAnchor,
362362
lineAnchor,
363363
fontVariant,
@@ -366,7 +366,7 @@ function axisTextKy(
366366
...options,
367367
dx: anchor === "left" ? +dx - tickSize - tickPadding + +insetLeft : +dx + +tickSize + +tickPadding - insetRight
368368
},
369-
function (scale, data, ticks, channels) {
369+
function (scale, data, ticks, tickFormat, channels) {
370370
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
371371
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
372372
}
@@ -383,8 +383,7 @@ function axisTextKx(
383383
tickSize,
384384
tickRotate = 0,
385385
tickPadding = Math.max(3, 9 - tickSize) + (Math.abs(tickRotate) >= 10 ? 4 * Math.cos(tickRotate * radians) : 0),
386-
tickFormat,
387-
text = typeof tickFormat === "function" ? tickFormat : undefined,
386+
text,
388387
textAnchor = Math.abs(tickRotate) >= 10 ? ((tickRotate < 0) ^ (anchor === "bottom") ? "start" : "end") : "middle",
389388
lineAnchor = Math.abs(tickRotate) >= 10 ? "middle" : anchor === "bottom" ? "top" : "bottom",
390389
fontVariant,
@@ -399,6 +398,7 @@ function axisTextKx(
399398
return axisMark(
400399
textX,
401400
k,
401+
anchor,
402402
`${k}-axis tick label`,
403403
data,
404404
{
@@ -413,7 +413,7 @@ function axisTextKx(
413413
...options,
414414
dy: anchor === "bottom" ? +dy + +tickSize + +tickPadding - insetBottom : +dy - tickSize - tickPadding + +insetTop
415415
},
416-
function (scale, data, ticks, channels) {
416+
function (scale, data, ticks, tickFormat, channels) {
417417
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
418418
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
419419
}
@@ -452,7 +452,7 @@ function gridKy(
452452
...options
453453
}
454454
) {
455-
return axisMark(ruleY, k, `${k}-grid`, data, {y, x1, x2, ...gridDefaults(options)});
455+
return axisMark(ruleY, k, anchor, `${k}-grid`, data, {y, x1, x2, ...gridDefaults(options)});
456456
}
457457

458458
function gridKx(
@@ -467,7 +467,7 @@ function gridKx(
467467
...options
468468
}
469469
) {
470-
return axisMark(ruleX, k, `${k}-grid`, data, {x, y1, y2, ...gridDefaults(options)});
470+
return axisMark(ruleX, k, anchor, `${k}-grid`, data, {x, y1, y2, ...gridDefaults(options)});
471471
}
472472

473473
function gridDefaults({
@@ -517,15 +517,17 @@ function labelOptions(
517517
};
518518
}
519519

520-
function axisMark(mark, k, ariaLabel, data, options, initialize) {
520+
function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) {
521521
let channels;
522522

523523
function axisInitializer(data, facets, _channels, scales, dimensions, context) {
524524
const initializeFacets = data == null && (k === "fx" || k === "fy");
525525
const {[k]: scale} = scales;
526526
if (!scale) throw new Error(`missing scale: ${k}`);
527-
let {ticks, tickSpacing, interval} = options;
528-
if (isTemporalScale(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined);
527+
let {ticks, tickFormat, interval} = options;
528+
// TODO what if ticks is a time interval implementation?
529+
// TODO allow ticks to be a function?
530+
if (hasTimeTicks(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined);
529531
if (data == null) {
530532
if (isIterable(ticks)) {
531533
data = arrayify(ticks);
@@ -542,21 +544,38 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
542544
const [min, max] = extent(scale.domain());
543545
data = interval.range(min, interval.offset(interval.floor(max))); // inclusive max
544546
} else {
545-
const [min, max] = extent(scale.range());
546-
ticks = (max - min) / (tickSpacing === undefined ? (k === "x" ? 80 : 35) : tickSpacing);
547+
ticks = inferTickCount(k, scale, options);
547548
data = scale.ticks(ticks);
548549
}
549550
}
550551
} else {
551552
data = scale.domain();
553+
if (isTimeInterval(scale.interval)) {
554+
const type = "utc"; // TODO infer type of ordinal time
555+
const [start, stop] = extent(data);
556+
if (interval !== undefined) data = maybeRangeInterval(interval, type).range(start, +stop + 1); // inclusive stop
557+
if (ticks === undefined) ticks = inferTickCount(k, scale, options);
558+
const n = Math.max(1, getSkip(data, ticks));
559+
const s = getMedianStep(data);
560+
const f = inferTimeFormat(s * n);
561+
const [i, I] = f;
562+
// const [j, J] = inferTimeFormat(s);
563+
data = maybeRangeInterval(I, type).range(start, +stop + 1); // inclusive stop
564+
// TODO check if isSubsumingInterval(interval, data)
565+
if (tickFormat === undefined) {
566+
const format = utcFormat; // TODO based on type
567+
const template = (f1, f2) => `${f1}\n${f2}`; // TODO based on anchor
568+
tickFormat = formatTimeInterval(i, format, template);
569+
}
570+
}
552571
}
553572
if (k === "y" || k === "x") {
554573
facets = [range(data)];
555574
} else {
556575
channels[k] = {scale: k, value: identity};
557576
}
558577
}
559-
initialize?.call(this, scale, data, ticks, channels);
578+
initialize?.call(this, scale, data, ticks, tickFormat, channels);
560579
const initializedChannels = Object.fromEntries(
561580
Object.entries(channels).map(([name, channel]) => {
562581
return [name, {...channel, value: valueof(data, channel.value)}];
@@ -580,8 +599,34 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
580599
return m;
581600
}
582601

602+
// Compute the positive number n such that taking every nth value from the
603+
// scale’s domain produces as close as possible to the desired number of ticks.
604+
// For example, if the domain has 100 values and 5 ticks are desired, n = 20.
605+
function getSkip(domain, ticks) {
606+
return domain.length / ticks;
607+
}
608+
609+
// Compute the median step s between adjacent values from the scale’s domain.
610+
function getMedianStep(domain) {
611+
return median(pairs(domain, (a, b) => Math.abs(b - a) || NaN));
612+
}
613+
614+
function inferTickCount(k, scale, options) {
615+
const {tickSpacing = k === "x" ? 80 : 35} = options;
616+
const [min, max] = extent(scale.range());
617+
return (max - min) / tickSpacing;
618+
}
619+
620+
// Returns true if the given interval subsumes (i.e., covers, is
621+
// capable of generating) all of the specified values.
622+
// function isSubsumingInterval(interval, values) {
623+
// return values.every((v) => interval.floor(v) >= v);
624+
// }
625+
583626
function inferTextChannel(scale, data, ticks, tickFormat, anchor) {
584-
return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)};
627+
return {
628+
value: typeof tickFormat === "function" ? tickFormat : inferTickFormat(scale, data, ticks, tickFormat, anchor)
629+
};
585630
}
586631

587632
// D3’s ordinal scales simply use toString by default, but if the ordinal scale
@@ -647,7 +692,7 @@ function inferScaleOrder(scale) {
647692
// Takes the scale label, and if this is not an ordinal scale and the label was
648693
// inferred from an associated channel, adds an orientation-appropriate arrow.
649694
function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {
650-
if (label == null || (label.inferred && isTemporalish(scale) && /^(date|time|year)$/i.test(label))) return;
695+
if (label == null || (label.inferred && hasTimeTicks(scale) && /^(date|time|year)$/i.test(label))) return;
651696
label = String(label); // coerce to a string after checking if inferred
652697
if (labelArrow === "auto") labelArrow = (!scale.bandwidth || scale.interval) && !/[]/.test(label);
653698
if (!labelArrow) return label;
@@ -684,6 +729,6 @@ function maybeLabelArrow(labelArrow = "auto") {
684729
: keyword(labelArrow, "labelArrow", ["auto", "up", "right", "down", "left"]);
685730
}
686731

687-
function isTemporalish(scale) {
688-
return isTemporalScale(scale) || scale.interval != null;
732+
function hasTimeTicks(scale) {
733+
return isTemporalScale(scale) || (isOrdinalScale(scale) && isTimeInterval(scale.interval));
689734
}

src/options.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,14 @@ export function maybeNiceInterval(interval, type) {
348348
return interval;
349349
}
350350

351+
export function isTimeInterval(t) {
352+
return isInterval(t) && typeof t?.floor === "function" && t.floor() instanceof Date;
353+
}
354+
355+
export function isInterval(t) {
356+
return typeof t?.range === "function";
357+
}
358+
351359
// This distinguishes between per-dimension options and a standalone value.
352360
export function maybeValue(value) {
353361
return value === undefined || isOptions(value) ? value : {value};

src/time.js

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3";
1+
import {bisector, extent, median, pairs, tickStep, 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";
@@ -12,22 +12,25 @@ const durationDay = durationHour * 24;
1212
const durationWeek = durationDay * 7;
1313
const durationMonth = durationDay * 30;
1414
const durationYear = durationDay * 365;
15+
const durationMin = Math.exp((Math.log(500) + Math.log(durationSecond)) / 2);
16+
const durationMax = Math.exp((Math.log(6 * durationMonth) + Math.log(durationYear)) / 2);
1517

18+
// [format, interval, step]; year and millisecond are handled dynamically
1619
// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33
1720
const formats = [
18-
["millisecond", 0.5 * durationSecond],
19-
["second", durationSecond],
20-
["second", 30 * durationSecond],
21-
["minute", durationMinute],
22-
["minute", 30 * durationMinute],
23-
["hour", durationHour],
24-
["hour", 12 * durationHour],
25-
["day", durationDay],
26-
["day", 2 * durationDay],
27-
["week", durationWeek],
28-
["month", durationMonth],
29-
["month", 3 * durationMonth],
30-
["year", durationYear]
21+
["second", "1 second", durationSecond],
22+
["second", "30 seconds", 30 * durationSecond],
23+
["minute", "1 minute", durationMinute],
24+
["minute", "30 minutes", 30 * durationMinute],
25+
["hour", "1 hour", durationHour],
26+
["hour", "12 hours", 12 * durationHour],
27+
["day", "1 day", durationDay],
28+
["day", "2 days", 2 * durationDay],
29+
["week", "1 week", durationWeek],
30+
["week", "2 weeks", 2 * durationWeek],
31+
["month", "1 month", durationMonth],
32+
["month", "3 months", 3 * durationMonth],
33+
["month", "6 months", 6 * durationMonth] // https://github.com/d3/d3-time/issues/46
3134
];
3235

3336
const timeIntervals = new Map([
@@ -110,15 +113,31 @@ export function isTimeYear(i) {
110113
return timeYear(date) >= date; // coercing equality
111114
}
112115

116+
// Compute the median difference between adjacent ticks, ignoring repeated
117+
// ticks; this implies an effective time interval, assuming that ticks are
118+
// regularly spaced; choose the largest format less than this interval so that
119+
// the ticks show the field that is changing. If the ticks are not available,
120+
// fallback to an approximation based on the desired number of ticks.
113121
export function formatTimeTicks(scale, data, ticks, anchor) {
114-
const format = scale.type === "time" ? timeFormat : utcFormat;
115-
const template =
122+
let step = median(pairs(data, (a, b) => Math.abs(b - a) || NaN));
123+
if (!(step > 0)) {
124+
const [start, stop] = extent(scale.domain());
125+
const count = typeof ticks === "number" ? ticks : 10;
126+
step = Math.abs(stop - start) / count;
127+
}
128+
return formatTimeInterval(
129+
inferTimeFormat(step)[0],
130+
scale.type === "time" ? timeFormat : utcFormat,
116131
anchor === "left" || anchor === "right"
117132
? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
118133
: anchor === "top"
119134
? (f1, f2) => `${f2}\n${f1}`
120-
: (f1, f2) => `${f1}\n${f2}`;
121-
switch (getTimeTicksInterval(scale, data, ticks)) {
135+
: (f1, f2) => `${f1}\n${f2}`
136+
);
137+
}
138+
139+
export function formatTimeInterval(interval, format, template) {
140+
switch (interval) {
122141
case "millisecond":
123142
return formatConditional(format(".%L"), format(":%M:%S"), template);
124143
case "second":
@@ -139,18 +158,14 @@ export function formatTimeTicks(scale, data, ticks, anchor) {
139158
throw new Error("unable to format time ticks");
140159
}
141160

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];
150-
const [start, stop] = extent(scale.domain());
151-
const count = typeof ticks === "number" ? ticks : 10;
152-
const step = Math.abs(stop - start) / count;
153-
return formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0];
161+
// Use the median step s to determine the standard time interval i that is
162+
// closest to the median step s times n (per 1). For example, if the scale’s
163+
// interval is day and n = 20, then i = month; if the scale’s interval is day
164+
// and n = 7, then i = week.
165+
export function inferTimeFormat(s) {
166+
if (s < durationMin) return (s = tickStep(0, s, 1)), ["millisecond", `${s} milliseconds`, s];
167+
if (s > durationMax) return (s = tickStep(0, s / durationYear, 1)), ["year", `${s} years`, s * durationYear];
168+
return formats[bisector(([, , step]) => Math.log(step)).center(formats, Math.log(s))];
154169
}
155170

156171
function formatConditional(format1, format2, template) {

src/transforms/bin.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
coerceDate,
1414
coerceNumbers,
1515
identity,
16+
isInterval,
1617
isIterable,
1718
isTemporal,
19+
isTimeInterval,
1820
labelof,
1921
map,
2022
maybeApplyInterval,
@@ -361,14 +363,6 @@ function isTimeThresholds(t) {
361363
return isTimeInterval(t) || (isIterable(t) && isTemporal(t));
362364
}
363365

364-
function isTimeInterval(t) {
365-
return isInterval(t) && typeof t === "function" && t() instanceof Date;
366-
}
367-
368-
function isInterval(t) {
369-
return typeof t?.range === "function";
370-
}
371-
372366
function bing(EX, EY) {
373367
return EX && EY
374368
? function* (I) {

test/output/bandClip2.svg

Lines changed: 6 additions & 6 deletions
Loading

0 commit comments

Comments
 (0)