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

better ordinal axes with intervals #1790

Merged
merged 23 commits into from
Aug 24, 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
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, scale.domain, undefined, tickFormat);
tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);

const swatches = create("div", context).attr(
"class",
Expand Down
143 changes: 95 additions & 48 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {extent, format, timeFormat, utcFormat} from "d3";
import {InternSet, extent, format, utcFormat} from "d3";
import {formatDefault} from "../format.js";
import {marks} from "../mark.js";
import {radians} from "../math.js";
import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js";
import {isIterable, isNoneish, isTemporal, orderof} from "../options.js";
import {isIterable, isNoneish, isTemporal, isInterval, orderof} from "../options.js";
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
import {isTemporalScale} from "../scales.js";
import {offset} from "../style.js";
import {formatTimeTicks, isTimeYear, isUtcYear} from "../time.js";
import {generalizeTimeInterval, inferTimeFormat, intervalDuration} from "../time.js";
import {initializer} from "../transforms/basic.js";
import {warn} from "../warnings.js";
import {ruleX, ruleY} from "./rule.js";
import {text, textX, textY} from "./text.js";
import {vectorX, vectorY} from "./vector.js";
Expand Down Expand Up @@ -277,7 +277,7 @@ function axisTickKy(
...options
}
) {
return axisMark(vectorY, k, `${k}-axis tick`, data, {
return axisMark(vectorY, k, anchor, `${k}-axis tick`, data, {
strokeWidth,
strokeLinecap,
strokeLinejoin,
Expand Down Expand Up @@ -311,7 +311,7 @@ function axisTickKx(
...options
}
) {
return axisMark(vectorX, k, `${k}-axis tick`, data, {
return axisMark(vectorX, k, anchor, `${k}-axis tick`, data, {
strokeWidth,
strokeLinejoin,
strokeLinecap,
Expand All @@ -336,8 +336,7 @@ function axisTextKy(
tickSize,
tickRotate = 0,
tickPadding = Math.max(3, 9 - tickSize) + (Math.abs(tickRotate) > 60 ? 4 * Math.cos(tickRotate * radians) : 0),
tickFormat,
text = typeof tickFormat === "function" ? tickFormat : undefined,
text,
textAnchor = Math.abs(tickRotate) > 60 ? "middle" : anchor === "left" ? "end" : "start",
lineAnchor = tickRotate > 60 ? "top" : tickRotate < -60 ? "bottom" : "middle",
fontVariant,
Expand All @@ -352,12 +351,13 @@ function axisTextKy(
return axisMark(
textY,
k,
anchor,
`${k}-axis tick label`,
data,
{
facetAnchor,
frameAnchor,
text: text === undefined ? null : text,
text,
textAnchor,
lineAnchor,
fontVariant,
Expand All @@ -366,7 +366,7 @@ function axisTextKy(
...options,
dx: anchor === "left" ? +dx - tickSize - tickPadding + +insetLeft : +dx + +tickSize + +tickPadding - insetRight
},
function (scale, data, ticks, channels) {
function (scale, data, ticks, tickFormat, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
}
Expand All @@ -383,8 +383,7 @@ function axisTextKx(
tickSize,
tickRotate = 0,
tickPadding = Math.max(3, 9 - tickSize) + (Math.abs(tickRotate) >= 10 ? 4 * Math.cos(tickRotate * radians) : 0),
tickFormat,
text = typeof tickFormat === "function" ? tickFormat : undefined,
text,
textAnchor = Math.abs(tickRotate) >= 10 ? ((tickRotate < 0) ^ (anchor === "bottom") ? "start" : "end") : "middle",
lineAnchor = Math.abs(tickRotate) >= 10 ? "middle" : anchor === "bottom" ? "top" : "bottom",
fontVariant,
Expand All @@ -399,6 +398,7 @@ function axisTextKx(
return axisMark(
textX,
k,
anchor,
`${k}-axis tick label`,
data,
{
Expand All @@ -413,7 +413,7 @@ function axisTextKx(
...options,
dy: anchor === "bottom" ? +dy + +tickSize + +tickPadding - insetBottom : +dy - tickSize - tickPadding + +insetTop
},
function (scale, data, ticks, channels) {
function (scale, data, ticks, tickFormat, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
if (text === undefined) channels.text = inferTextChannel(scale, data, ticks, tickFormat, anchor);
}
Expand Down Expand Up @@ -452,7 +452,7 @@ function gridKy(
...options
}
) {
return axisMark(ruleY, k, `${k}-grid`, data, {y, x1, x2, ...gridDefaults(options)});
return axisMark(ruleY, k, anchor, `${k}-grid`, data, {y, x1, x2, ...gridDefaults(options)});
}

function gridKx(
Expand All @@ -467,7 +467,7 @@ function gridKx(
...options
}
) {
return axisMark(ruleX, k, `${k}-grid`, data, {x, y1, y2, ...gridDefaults(options)});
return axisMark(ruleX, k, anchor, `${k}-grid`, data, {x, y1, y2, ...gridDefaults(options)});
}

function gridDefaults({
Expand Down Expand Up @@ -517,46 +517,83 @@ function labelOptions(
};
}

function axisMark(mark, k, ariaLabel, data, options, initialize) {
function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) {
let channels;

function axisInitializer(data, facets, _channels, scales, dimensions, context) {
const initializeFacets = data == null && (k === "fx" || k === "fy");
const {[k]: scale} = scales;
if (!scale) throw new Error(`missing scale: ${k}`);
let {ticks, tickSpacing, interval} = options;
if (isTemporalScale(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined);
const domain = scale.domain();
let {interval, ticks, tickFormat, tickSpacing = k === "x" ? 80 : 35} = options;
// For a scale with a temporal domain, also allow the ticks to be specified
// as a string which is promoted to a time interval. In the case of ordinal
// scales, the interval is interpreted as UTC.
if (typeof ticks === "string" && hasTemporalDomain(scale)) (interval = ticks), (ticks = undefined);
// The interval axis option is an alternative method of specifying ticks;
// for example, for a numeric scale, ticks = 5 means “about 5 ticks” whereas
// interval = 5 means “ticks every 5 units”. (This is not to be confused
// with the interval scale option, which affects the scale’s behavior!)
// Lastly use the tickSpacing option to infer the desired tick count.
if (ticks === undefined) ticks = maybeRangeInterval(interval, scale.type) ?? inferTickCount(scale, tickSpacing);
if (data == null) {
if (isIterable(ticks)) {
// Use explicit ticks, if specified.
data = arrayify(ticks);
} else if (scale.ticks) {
if (ticks !== undefined) {
data = scale.ticks(ticks);
} else if (isInterval(ticks)) {
// Use the tick interval, if specified.
data = inclusiveRange(ticks, ...extent(domain));
} else if (scale.interval) {
// If the scale interval is a standard time interval such as "day", we
// may be able to generalize the scale interval it to a larger aligned
// time interval to create the desired number of ticks.
let interval = scale.interval;
if (scale.ticks) {
const [min, max] = extent(domain);
const n = (max - min) / interval[intervalDuration]; // current tick count
// We don’t explicitly check that given interval is a time interval;
// in that case the generalized interval will be undefined, just like
// a nonstandard interval. TODO Generalize integer intervals, too.
interval = generalizeTimeInterval(interval, n / ticks) ?? interval;
data = inclusiveRange(interval, min, max);
} else {
interval = maybeRangeInterval(interval === undefined ? scale.interval : interval, scale.type);
if (interval !== undefined) {
// For time scales, we could pass the interval directly to
// scale.ticks because it’s supported by d3.utcTicks; but
// quantitative scales and d3.ticks do not support numeric
// intervals for scale.ticks, so we compute them here.
const [min, max] = extent(scale.domain());
data = interval.range(min, interval.offset(interval.floor(max))); // inclusive max
} else {
const [min, max] = extent(scale.range());
ticks = (max - min) / (tickSpacing === undefined ? (k === "x" ? 80 : 35) : tickSpacing);
data = scale.ticks(ticks);
}
data = domain;
const n = data.length; // current tick count
interval = generalizeTimeInterval(interval, n / ticks) ?? interval;
if (interval !== scale.interval) data = inclusiveRange(interval, ...extent(data));
}
if (interval === scale.interval) {
// If we weren’t able to generalize the scale’s interval, compute the
// positive number n such that taking every nth value from the scale’s
// domain produces as close as possible to the desired number of
// ticks. For example, if the domain has 100 values and 5 ticks are
// desired, n = 20.
const n = Math.round(data.length / ticks);
if (n > 1) data = data.filter((d, i) => i % n === 0);
}
} else if (scale.ticks) {
data = scale.ticks(ticks);
} else {
data = scale.domain();
// For ordinal scales, the domain will already be generated using the
// scale’s interval, if any.
data = domain;
}
if (!scale.ticks && data.length && data !== domain) {
// For ordinal scales, intersect the ticks with the scale domain since
// the scale is only defined on its domain. If all of the ticks are
// removed, then warn that the ticks and scale domain may be misaligned
// (e.g., "year" ticks and "4 weeks" interval).
const domainSet = new InternSet(domain);
data = data.filter((d) => domainSet.has(d));
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
}
if (k === "y" || k === "x") {
facets = [range(data)];
} else {
channels[k] = {scale: k, value: identity};
}
}
initialize?.call(this, scale, data, ticks, channels);
initialize?.call(this, scale, data, ticks, tickFormat, channels);
const initializedChannels = Object.fromEntries(
Object.entries(channels).map(([name, channel]) => {
return [name, {...channel, value: valueof(data, channel.value)}];
Expand All @@ -580,29 +617,39 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
return m;
}

function inferTickCount(scale, tickSpacing) {
const [min, max] = extent(scale.range());
return (max - min) / tickSpacing;
}

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.
// interval to the ordinal scale), we want Plot’s default formatter. And for
// time ticks, we want to use the multi-line time format (e.g., Jan 26) if
// possible, or the default ISO format (2014-01-26). TODO We need a better way
// to infer whether the ordinal scale is UTC or local time.
export function inferTickFormat(scale, data, ticks, tickFormat, anchor) {
return tickFormat === undefined && isTemporalScale(scale)
? formatTimeTicks(scale, data, ticks, anchor)
return typeof tickFormat === "function"
? tickFormat
: tickFormat === undefined && data && isTemporal(data)
? inferTimeFormat(data, anchor) ?? formatDefault
: scale.tickFormat
? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat)
? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat)
: tickFormat === undefined
? isUtcYear(scale.interval)
? utcFormat("%Y")
: isTimeYear(scale.interval)
? timeFormat("%Y")
: formatDefault
? formatDefault
: typeof tickFormat === "string"
? (isTemporal(scale.domain()) ? utcFormat : format)(tickFormat)
: constant(tickFormat);
}

function inclusiveRange(interval, min, max) {
return interval.range(min, interval.offset(interval.floor(max)));
}

const shapeTickBottom = {
draw(context, l) {
context.moveTo(0, 0);
Expand Down Expand Up @@ -647,7 +694,7 @@ function inferScaleOrder(scale) {
// Takes the scale label, and if this is not an ordinal scale and the label was
// inferred from an associated channel, adds an orientation-appropriate arrow.
function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {
if (label == null || (label.inferred && isTemporalish(scale) && /^(date|time|year)$/i.test(label))) return;
if (label == null || (label.inferred && hasTemporalDomain(scale) && /^(date|time|year)$/i.test(label))) return;
label = String(label); // coerce to a string after checking if inferred
if (labelArrow === "auto") labelArrow = (!scale.bandwidth || scale.interval) && !/[↑↓→←]/.test(label);
if (!labelArrow) return label;
Expand Down Expand Up @@ -684,6 +731,6 @@ function maybeLabelArrow(labelArrow = "auto") {
: keyword(labelArrow, "labelArrow", ["auto", "up", "right", "down", "left"]);
}

function isTemporalish(scale) {
return isTemporalScale(scale) || scale.interval != null;
function hasTemporalDomain(scale) {
return isTemporal(scale.domain());
}
8 changes: 8 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ export function maybeNiceInterval(interval, type) {
return interval;
}

export function isTimeInterval(t) {
return isInterval(t) && typeof t?.floor === "function" && t.floor() instanceof Date;
}

export function isInterval(t) {
return typeof t?.range === "function";
}

// This distinguishes between per-dimension options and a standalone value.
export function maybeValue(value) {
return value === undefined || isOptions(value) ? value : {value};
Expand Down
Loading