Skip to content

Commit e3e794e

Browse files
mbostockFil
andauthored
better ordinal axes with intervals (#1790)
* ordinal time axis * filter ordinal ticks with numeric intervals * checkpoint * simplify hasTimeTicks * fix nullish check * filter approach * inferTimeFormat * tidy * prune redundant formats * tidy * comment * filter ticks, not just text * warn on misaligned intervals * dense grid for sparseCell * add missing test snapshot * more robust inferTimeFormat * detect and generalize standard time intervals * test: temporal interval on the facet scale * improve temporal scales, too * better edge cases * tweak comment * move tickFormat function detection * minimize diff --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent dbf913b commit e3e794e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+5367
-608
lines changed

src/legends/swatches.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function legendItems(scale, options = {}, swatch) {
8686
} = options;
8787
const context = createContext(options);
8888
className = maybeClassName(className);
89-
if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);
89+
tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);
9090

9191
const swatches = create("div", context).attr(
9292
"class",

src/marks/axis.js

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import {extent, format, timeFormat, utcFormat} from "d3";
1+
import {InternSet, extent, format, 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, isInterval, orderof} from "../options.js";
77
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
8-
import {isTemporalScale} from "../scales.js";
98
import {offset} from "../style.js";
10-
import {formatTimeTicks, isTimeYear, isUtcYear} from "../time.js";
9+
import {generalizeTimeInterval, inferTimeFormat, intervalDuration} from "../time.js";
1110
import {initializer} from "../transforms/basic.js";
11+
import {warn} from "../warnings.js";
1212
import {ruleX, ruleY} from "./rule.js";
1313
import {text, textX, textY} from "./text.js";
1414
import {vectorX, vectorY} from "./vector.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,46 +517,83 @@ 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+
const domain = scale.domain();
528+
let {interval, ticks, tickFormat, tickSpacing = k === "x" ? 80 : 35} = options;
529+
// For a scale with a temporal domain, also allow the ticks to be specified
530+
// as a string which is promoted to a time interval. In the case of ordinal
531+
// scales, the interval is interpreted as UTC.
532+
if (typeof ticks === "string" && hasTemporalDomain(scale)) (interval = ticks), (ticks = undefined);
533+
// The interval axis option is an alternative method of specifying ticks;
534+
// for example, for a numeric scale, ticks = 5 means “about 5 ticks” whereas
535+
// interval = 5 means “ticks every 5 units”. (This is not to be confused
536+
// with the interval scale option, which affects the scale’s behavior!)
537+
// Lastly use the tickSpacing option to infer the desired tick count.
538+
if (ticks === undefined) ticks = maybeRangeInterval(interval, scale.type) ?? inferTickCount(scale, tickSpacing);
529539
if (data == null) {
530540
if (isIterable(ticks)) {
541+
// Use explicit ticks, if specified.
531542
data = arrayify(ticks);
532-
} else if (scale.ticks) {
533-
if (ticks !== undefined) {
534-
data = scale.ticks(ticks);
543+
} else if (isInterval(ticks)) {
544+
// Use the tick interval, if specified.
545+
data = inclusiveRange(ticks, ...extent(domain));
546+
} else if (scale.interval) {
547+
// If the scale interval is a standard time interval such as "day", we
548+
// may be able to generalize the scale interval it to a larger aligned
549+
// time interval to create the desired number of ticks.
550+
let interval = scale.interval;
551+
if (scale.ticks) {
552+
const [min, max] = extent(domain);
553+
const n = (max - min) / interval[intervalDuration]; // current tick count
554+
// We don’t explicitly check that given interval is a time interval;
555+
// in that case the generalized interval will be undefined, just like
556+
// a nonstandard interval. TODO Generalize integer intervals, too.
557+
interval = generalizeTimeInterval(interval, n / ticks) ?? interval;
558+
data = inclusiveRange(interval, min, max);
535559
} else {
536-
interval = maybeRangeInterval(interval === undefined ? scale.interval : interval, scale.type);
537-
if (interval !== undefined) {
538-
// For time scales, we could pass the interval directly to
539-
// scale.ticks because it’s supported by d3.utcTicks; but
540-
// quantitative scales and d3.ticks do not support numeric
541-
// intervals for scale.ticks, so we compute them here.
542-
const [min, max] = extent(scale.domain());
543-
data = interval.range(min, interval.offset(interval.floor(max))); // inclusive max
544-
} else {
545-
const [min, max] = extent(scale.range());
546-
ticks = (max - min) / (tickSpacing === undefined ? (k === "x" ? 80 : 35) : tickSpacing);
547-
data = scale.ticks(ticks);
548-
}
560+
data = domain;
561+
const n = data.length; // current tick count
562+
interval = generalizeTimeInterval(interval, n / ticks) ?? interval;
563+
if (interval !== scale.interval) data = inclusiveRange(interval, ...extent(data));
564+
}
565+
if (interval === scale.interval) {
566+
// If we weren’t able to generalize the scale’s interval, compute the
567+
// positive number n such that taking every nth value from the scale’s
568+
// domain produces as close as possible to the desired number of
569+
// ticks. For example, if the domain has 100 values and 5 ticks are
570+
// desired, n = 20.
571+
const n = Math.round(data.length / ticks);
572+
if (n > 1) data = data.filter((d, i) => i % n === 0);
549573
}
574+
} else if (scale.ticks) {
575+
data = scale.ticks(ticks);
550576
} else {
551-
data = scale.domain();
577+
// For ordinal scales, the domain will already be generated using the
578+
// scale’s interval, if any.
579+
data = domain;
580+
}
581+
if (!scale.ticks && data.length && data !== domain) {
582+
// For ordinal scales, intersect the ticks with the scale domain since
583+
// the scale is only defined on its domain. If all of the ticks are
584+
// removed, then warn that the ticks and scale domain may be misaligned
585+
// (e.g., "year" ticks and "4 weeks" interval).
586+
const domainSet = new InternSet(domain);
587+
data = data.filter((d) => domainSet.has(d));
588+
if (!data.length) warn(`Warning: the ${k}-axis ticks appear to not align with the scale domain, resulting in no ticks. Try different ticks?`); // prettier-ignore
552589
}
553590
if (k === "y" || k === "x") {
554591
facets = [range(data)];
555592
} else {
556593
channels[k] = {scale: k, value: identity};
557594
}
558595
}
559-
initialize?.call(this, scale, data, ticks, channels);
596+
initialize?.call(this, scale, data, ticks, tickFormat, channels);
560597
const initializedChannels = Object.fromEntries(
561598
Object.entries(channels).map(([name, channel]) => {
562599
return [name, {...channel, value: valueof(data, channel.value)}];
@@ -580,29 +617,39 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
580617
return m;
581618
}
582619

620+
function inferTickCount(scale, tickSpacing) {
621+
const [min, max] = extent(scale.range());
622+
return (max - min) / tickSpacing;
623+
}
624+
583625
function inferTextChannel(scale, data, ticks, tickFormat, anchor) {
584626
return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)};
585627
}
586628

587629
// D3’s ordinal scales simply use toString by default, but if the ordinal scale
588630
// domain (or ticks) are numbers or dates (say because we’re applying a time
589-
// interval to the ordinal scale), we want Plot’s default formatter.
631+
// interval to the ordinal scale), we want Plot’s default formatter. And for
632+
// time ticks, we want to use the multi-line time format (e.g., Jan 26) if
633+
// possible, or the default ISO format (2014-01-26). TODO We need a better way
634+
// to infer whether the ordinal scale is UTC or local time.
590635
export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
591-
return tickFormat === undefined && isTemporalScale(scale)
592-
? formatTimeTicks(scale, data, ticks, anchor)
636+
return typeof tickFormat === "function"
637+
? tickFormat
638+
: tickFormat === undefined && data && isTemporal(data)
639+
? inferTimeFormat(data, anchor) ?? formatDefault
593640
: scale.tickFormat
594-
? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat)
641+
? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat)
595642
: tickFormat === undefined
596-
? isUtcYear(scale.interval)
597-
? utcFormat("%Y")
598-
: isTimeYear(scale.interval)
599-
? timeFormat("%Y")
600-
: formatDefault
643+
? formatDefault
601644
: typeof tickFormat === "string"
602645
? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat)
603646
: constant(tickFormat);
604647
}
605648

649+
function inclusiveRange(interval, min, max) {
650+
return interval.range(min, interval.offset(interval.floor(max)));
651+
}
652+
606653
const shapeTickBottom = {
607654
draw(context, l) {
608655
context.moveTo(0, 0);
@@ -647,7 +694,7 @@ function inferScaleOrder(scale) {
647694
// Takes the scale label, and if this is not an ordinal scale and the label was
648695
// inferred from an associated channel, adds an orientation-appropriate arrow.
649696
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;
697+
if (label == null || (label.inferred && hasTemporalDomain(scale) && /^(date|time|year)$/i.test(label))) return;
651698
label = String(label); // coerce to a string after checking if inferred
652699
if (labelArrow === "auto") labelArrow = (!scale.bandwidth || scale.interval) && !/[]/.test(label);
653700
if (!labelArrow) return label;
@@ -684,6 +731,6 @@ function maybeLabelArrow(labelArrow = "auto") {
684731
: keyword(labelArrow, "labelArrow", ["auto", "up", "right", "down", "left"]);
685732
}
686733

687-
function isTemporalish(scale) {
688-
return isTemporalScale(scale) || scale.interval != null;
734+
function hasTemporalDomain(scale) {
735+
return isTemporal(scale.domain());
689736
}

src/options.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,14 @@ export function maybeNiceInterval(interval, type) {
357357
return interval;
358358
}
359359

360+
export function isTimeInterval(t) {
361+
return isInterval(t) && typeof t?.floor === "function" && t.floor() instanceof Date;
362+
}
363+
364+
export function isInterval(t) {
365+
return typeof t?.range === "function";
366+
}
367+
360368
// This distinguishes between per-dimension options and a standalone value.
361369
export function maybeValue(value) {
362370
return value === undefined || isOptions(value) ? value : {value};

0 commit comments

Comments
 (0)