Skip to content

Commit

Permalink
ordinal time axis
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Aug 16, 2023
1 parent 6adee12 commit 11d50fd
Show file tree
Hide file tree
Showing 19 changed files with 707 additions and 298 deletions.
95 changes: 70 additions & 25 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {extent, format, timeFormat, utcFormat} from "d3";
import {extent, format, median, pairs, timeFormat, 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, isTimeInterval, orderof} from "../options.js";
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
import {isTemporalScale} from "../scales.js";
import {isOrdinalScale, isTemporalScale} from "../scales.js";
import {offset} from "../style.js";
import {formatTimeTicks, isTimeYear, isUtcYear} from "../time.js";
import {formatTimeInterval, formatTimeTicks, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js";
import {initializer} from "../transforms/basic.js";
import {ruleX, ruleY} from "./rule.js";
import {text, textX, textY} from "./text.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,15 +517,17 @@ 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);
let {ticks, tickFormat, interval} = options;
// TODO what if ticks is a time interval implementation?
// TODO allow ticks to be a function?
if (hasTimeTicks(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined);
if (data == null) {
if (isIterable(ticks)) {
data = arrayify(ticks);
Expand All @@ -542,21 +544,38 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
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);
ticks = inferTickCount(k, scale, options);
data = scale.ticks(ticks);
}
}
} else {
data = scale.domain();
if (isTimeInterval(scale.interval)) {
const type = "utc"; // TODO infer type of ordinal time
const [start, stop] = extent(data);
if (interval !== undefined) data = maybeRangeInterval(interval, type).range(start, +stop + 1); // inclusive stop
if (ticks === undefined) ticks = inferTickCount(k, scale, options);
const n = Math.max(1, getSkip(data, ticks));
const s = getMedianStep(data);
const f = inferTimeFormat(s * n);
const [i, I] = f;
// const [j, J] = inferTimeFormat(s);
data = maybeRangeInterval(I, type).range(start, +stop + 1); // inclusive stop
// TODO check if isSubsumingInterval(interval, data)
if (tickFormat === undefined) {
const format = utcFormat; // TODO based on type
const template = (f1, f2) => `${f1}\n${f2}`; // TODO based on anchor
tickFormat = formatTimeInterval(i, format, template);
}
}
}
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,8 +599,34 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
return m;
}

// 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.
function getSkip(domain, ticks) {
return domain.length / ticks;
}

// Compute the median step s between adjacent values from the scale’s domain.
function getMedianStep(domain) {
return median(pairs(domain, (a, b) => Math.abs(b - a) || NaN));
}

function inferTickCount(k, scale, options) {
const {tickSpacing = k === "x" ? 80 : 35} = options;
const [min, max] = extent(scale.range());
return (max - min) / tickSpacing;
}

// Returns true if the given interval subsumes (i.e., covers, is
// capable of generating) all of the specified values.
// function isSubsumingInterval(interval, values) {
// return values.every((v) => interval.floor(v) >= v);
// }

function inferTextChannel(scale, data, ticks, tickFormat, anchor) {
return {value: inferTickFormat(scale, data, ticks, tickFormat, anchor)};
return {
value: typeof tickFormat === "function" ? tickFormat : inferTickFormat(scale, data, ticks, tickFormat, anchor)
};
}

// D3’s ordinal scales simply use toString by default, but if the ordinal scale
Expand Down Expand Up @@ -647,7 +692,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 && hasTimeTicks(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 +729,6 @@ function maybeLabelArrow(labelArrow = "auto") {
: keyword(labelArrow, "labelArrow", ["auto", "up", "right", "down", "left"]);
}

function isTemporalish(scale) {
return isTemporalScale(scale) || scale.interval != null;
function hasTimeTicks(scale) {
return isTemporalScale(scale) || (isOrdinalScale(scale) && isTimeInterval(scale.interval));
}
8 changes: 8 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,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
75 changes: 45 additions & 30 deletions src/time.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3";
import {bisector, extent, median, pairs, tickStep, 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";
Expand All @@ -12,22 +12,25 @@ const durationDay = durationHour * 24;
const durationWeek = durationDay * 7;
const durationMonth = durationDay * 30;
const durationYear = durationDay * 365;
const durationMin = Math.exp((Math.log(500) + Math.log(durationSecond)) / 2);
const durationMax = Math.exp((Math.log(6 * durationMonth) + Math.log(durationYear)) / 2);

// [format, interval, step]; year and millisecond are handled dynamically
// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33
const formats = [
["millisecond", 0.5 * durationSecond],
["second", durationSecond],
["second", 30 * durationSecond],
["minute", durationMinute],
["minute", 30 * durationMinute],
["hour", durationHour],
["hour", 12 * durationHour],
["day", durationDay],
["day", 2 * durationDay],
["week", durationWeek],
["month", durationMonth],
["month", 3 * durationMonth],
["year", durationYear]
["second", "1 second", durationSecond],
["second", "30 seconds", 30 * durationSecond],
["minute", "1 minute", durationMinute],
["minute", "30 minutes", 30 * durationMinute],
["hour", "1 hour", durationHour],
["hour", "12 hours", 12 * durationHour],
["day", "1 day", durationDay],
["day", "2 days", 2 * durationDay],
["week", "1 week", durationWeek],
["week", "2 weeks", 2 * durationWeek],
["month", "1 month", durationMonth],
["month", "3 months", 3 * durationMonth],
["month", "6 months", 6 * durationMonth] // https://github.com/d3/d3-time/issues/46
];

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

// 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.
export function formatTimeTicks(scale, data, ticks, anchor) {
const format = scale.type === "time" ? timeFormat : utcFormat;
const template =
let step = median(pairs(data, (a, b) => Math.abs(b - a) || NaN));
if (!(step > 0)) {
const [start, stop] = extent(scale.domain());
const count = typeof ticks === "number" ? ticks : 10;
step = Math.abs(stop - start) / count;
}
return formatTimeInterval(
inferTimeFormat(step)[0],
scale.type === "time" ? timeFormat : utcFormat,
anchor === "left" || anchor === "right"
? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
: anchor === "top"
? (f1, f2) => `${f2}\n${f1}`
: (f1, f2) => `${f1}\n${f2}`;
switch (getTimeTicksInterval(scale, data, ticks)) {
: (f1, f2) => `${f1}\n${f2}`
);
}

export function formatTimeInterval(interval, format, template) {
switch (interval) {
case "millisecond":
return formatConditional(format(".%L"), format(":%M:%S"), template);
case "second":
Expand All @@ -139,18 +158,14 @@ export function formatTimeTicks(scale, data, ticks, anchor) {
throw new Error("unable to format time 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;
const step = Math.abs(stop - start) / count;
return formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0];
// Use the median step s to determine the standard time interval i that is
// closest to the median step s times n (per 1). For example, if the scale’s
// interval is day and n = 20, then i = month; if the scale’s interval is day
// and n = 7, then i = week.
export function inferTimeFormat(s) {
if (s < durationMin) return (s = tickStep(0, s, 1)), ["millisecond", `${s} milliseconds`, s];
if (s > durationMax) return (s = tickStep(0, s / durationYear, 1)), ["year", `${s} years`, s * durationYear];
return formats[bisector(([, , step]) => Math.log(step)).center(formats, Math.log(s))];
}

function formatConditional(format1, format2, template) {
Expand Down
10 changes: 2 additions & 8 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
coerceDate,
coerceNumbers,
identity,
isInterval,
isIterable,
isTemporal,
isTimeInterval,
labelof,
map,
maybeApplyInterval,
Expand Down Expand Up @@ -361,14 +363,6 @@ function isTimeThresholds(t) {
return isTimeInterval(t) || (isIterable(t) && isTemporal(t));
}

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

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

function bing(EX, EY) {
return EX && EY
? function* (I) {
Expand Down
12 changes: 6 additions & 6 deletions test/output/bandClip2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 11d50fd

Please sign in to comment.