diff --git a/src/legends/swatches.js b/src/legends/swatches.js
index 5685f5c9e3..e5876aa546 100644
--- a/src/legends/swatches.js
+++ b/src/legends/swatches.js
@@ -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",
diff --git a/src/marks/axis.js b/src/marks/axis.js
index 28c5180d06..6a3d51f847 100644
--- a/src/marks/axis.js
+++ b/src/marks/axis.js
@@ -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";
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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);
}
@@ -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,
@@ -399,6 +398,7 @@ function axisTextKx(
return axisMark(
textX,
k,
+ anchor,
`${k}-axis tick label`,
data,
{
@@ -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);
}
@@ -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(
@@ -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({
@@ -517,38 +517,75 @@ 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)];
@@ -556,7 +593,7 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
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)}];
@@ -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);
@@ -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;
@@ -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());
}
diff --git a/src/options.js b/src/options.js
index 3fdcbd8bff..a391623c61 100644
--- a/src/options.js
+++ b/src/options.js
@@ -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};
diff --git a/src/time.js b/src/time.js
index 661a928184..fcfccc8980 100644
--- a/src/time.js
+++ b/src/time.js
@@ -1,4 +1,4 @@
-import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3";
+import {bisector, max, pairs, 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";
@@ -14,37 +14,76 @@ const durationMonth = durationDay * 30;
const durationYear = durationDay * 365;
// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33
-const formats = [
- ["millisecond", 0.5 * durationSecond],
+const tickIntervals = [
+ ["millisecond", 1],
+ ["2 milliseconds", 2],
+ ["5 milliseconds", 5],
+ ["10 milliseconds", 10],
+ ["20 milliseconds", 20],
+ ["50 milliseconds", 50],
+ ["100 milliseconds", 100],
+ ["200 milliseconds", 200],
+ ["500 milliseconds", 500],
["second", durationSecond],
- ["second", 30 * durationSecond],
+ ["5 seconds", 5 * durationSecond],
+ ["15 seconds", 15 * durationSecond],
+ ["30 seconds", 30 * durationSecond],
["minute", durationMinute],
- ["minute", 30 * durationMinute],
+ ["5 minutes", 5 * durationMinute],
+ ["15 minutes", 15 * durationMinute],
+ ["30 minutes", 30 * durationMinute],
["hour", durationHour],
- ["hour", 12 * durationHour],
+ ["3 hours", 3 * durationHour],
+ ["6 hours", 6 * durationHour],
+ ["12 hours", 12 * durationHour],
["day", durationDay],
- ["day", 2 * durationDay],
+ ["2 days", 2 * durationDay],
["week", durationWeek],
+ ["2 weeks", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46
["month", durationMonth],
- ["month", 3 * durationMonth],
- ["year", durationYear]
+ ["3 months", 3 * durationMonth],
+ ["6 months", 6 * durationMonth], // https://github.com/d3/d3-time/issues/46
+ ["year", durationYear],
+ ["2 years", 2 * durationYear],
+ ["5 years", 5 * durationYear],
+ ["10 years", 10 * durationYear],
+ ["20 years", 20 * durationYear],
+ ["50 years", 50 * durationYear],
+ ["100 years", 100 * durationYear] // TODO generalize to longer time scales
];
+const durations = new Map([
+ ["second", durationSecond],
+ ["minute", durationMinute],
+ ["hour", durationHour],
+ ["day", durationDay],
+ ["monday", durationWeek],
+ ["tuesday", durationWeek],
+ ["wednesday", durationWeek],
+ ["thursday", durationWeek],
+ ["friday", durationWeek],
+ ["saturday", durationWeek],
+ ["sunday", durationWeek],
+ ["week", durationWeek],
+ ["month", durationMonth],
+ ["year", durationYear]
+]);
+
const timeIntervals = new Map([
["second", timeSecond],
["minute", timeMinute],
["hour", timeHour],
- ["day", timeDay], // TODO local time equivalent of unixDay?
- ["week", timeWeek],
- ["month", timeMonth],
- ["year", timeYear],
+ ["day", timeDay], // https://github.com/d3/d3-time/issues/62
["monday", timeMonday],
["tuesday", timeTuesday],
["wednesday", timeWednesday],
["thursday", timeThursday],
["friday", timeFriday],
["saturday", timeSaturday],
- ["sunday", timeSunday]
+ ["sunday", timeSunday],
+ ["week", timeWeek],
+ ["month", timeMonth],
+ ["year", timeYear]
]);
const utcIntervals = new Map([
@@ -52,19 +91,58 @@ const utcIntervals = new Map([
["minute", utcMinute],
["hour", utcHour],
["day", unixDay],
- ["week", utcWeek],
- ["month", utcMonth],
- ["year", utcYear],
["monday", utcMonday],
["tuesday", utcTuesday],
["wednesday", utcWednesday],
["thursday", utcThursday],
["friday", utcFriday],
["saturday", utcSaturday],
- ["sunday", utcSunday]
+ ["sunday", utcSunday],
+ ["week", utcWeek],
+ ["month", utcMonth],
+ ["year", utcYear]
]);
-function parseInterval(input, intervals) {
+// These hidden fields describe standard intervals so that we can, for example,
+// generalize a scale’s time interval to a larger ticks time interval to reduce
+// the number of displayed ticks. TODO We could instead allow the interval
+// implementation to expose a “generalize” method that returns a larger, aligned
+// interval; that would allow us to move this logic to D3, and allow
+// generalization even when a custom interval is provided.
+export const intervalDuration = Symbol("intervalDuration");
+export const intervalType = Symbol("intervalType");
+
+// We greedily mutate D3’s standard intervals on load so that the hidden fields
+// are available even if specified as e.g. d3.utcMonth instead of "month".
+for (const [name, interval] of timeIntervals) {
+ interval[intervalDuration] = durations.get(name);
+ interval[intervalType] = "time";
+}
+for (const [name, interval] of utcIntervals) {
+ interval[intervalDuration] = durations.get(name);
+ interval[intervalType] = "utc";
+}
+
+// An interleaved array of UTC and local time intervals, in descending order
+// from largest to smallest, used to determine the most specific standard time
+// format for a given array of dates. This is a subset of the tick intervals
+// listed above; we only need the breakpoints where the format changes.
+const formatIntervals = [
+ ["year", utcYear, "utc"],
+ ["year", timeYear, "time"],
+ ["month", utcMonth, "utc"],
+ ["month", timeMonth, "time"],
+ ["day", unixDay, "utc", 6 * durationMonth],
+ ["day", timeDay, "time", 6 * durationMonth],
+ // Below day, local time typically has an hourly offset from UTC and hence the
+ // two are aligned and indistinguishable; therefore, we only consider UTC, and
+ // we don’t consider these if the domain only has a single value.
+ ["hour", utcHour, "utc", 3 * durationDay],
+ ["minute", utcMinute, "utc", 6 * durationHour],
+ ["second", utcSecond, "utc", 30 * durationMinute]
+];
+
+function parseInterval(input, intervals, type) {
let name = `${input}`.toLowerCase();
if (name.endsWith("s")) name = name.slice(0, -1); // drop plural
let period = 1;
@@ -85,40 +163,38 @@ function parseInterval(input, intervals) {
}
let interval = intervals.get(name);
if (!interval) throw new Error(`unknown interval: ${input}`);
- if (!(period > 1)) return interval;
- if (!interval.every) throw new Error(`non-periodic interval: ${name}`);
- return interval.every(period);
+ if (period > 1) {
+ if (!interval.every) throw new Error(`non-periodic interval: ${name}`);
+ interval = interval.every(period);
+ interval[intervalDuration] = durations.get(name) * period;
+ interval[intervalType] = type;
+ }
+ return interval;
}
export function maybeTimeInterval(interval) {
- return parseInterval(interval, timeIntervals);
+ return parseInterval(interval, timeIntervals, "time");
}
export function maybeUtcInterval(interval) {
- return parseInterval(interval, utcIntervals);
-}
-
-export function isUtcYear(i) {
- if (!i) return false;
- const date = i.floor(new Date(Date.UTC(2000, 11, 31)));
- return utcYear(date) >= date; // coercing equality
+ return parseInterval(interval, utcIntervals, "utc");
}
-export function isTimeYear(i) {
- if (!i) return false;
- const date = i.floor(new Date(2000, 11, 31));
- return timeYear(date) >= date; // coercing equality
+// If the given interval is a standard time interval, we may be able to promote
+// it a larger aligned time interval, rather than showing every nth tick.
+export function generalizeTimeInterval(interval, n) {
+ if (!(n > 1)) return; // no need to generalize
+ const duration = interval[intervalDuration];
+ if (!tickIntervals.some(([, d]) => d === duration)) return; // nonstandard or unknown interval
+ if (duration % durationDay === 0 && durationDay < duration && duration < durationMonth) return; // not generalizable
+ const [i] = tickIntervals[bisector(([, step]) => Math.log(step)).center(tickIntervals, Math.log(duration * n))];
+ return (interval[intervalType] === "time" ? maybeTimeInterval : maybeUtcInterval)(i);
}
-export function formatTimeTicks(scale, data, ticks, anchor) {
- const format = scale.type === "time" ? timeFormat : utcFormat;
- const template =
- 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)) {
+function formatTimeInterval(name, type, anchor) {
+ const format = type === "time" ? timeFormat : utcFormat;
+ const template = getTimeTemplate(anchor);
+ switch (name) {
case "millisecond":
return formatConditional(format(".%L"), format(":%M:%S"), template);
case "second":
@@ -129,8 +205,6 @@ export function formatTimeTicks(scale, data, ticks, anchor) {
return formatConditional(format("%-I %p"), format("%b %-d"), template);
case "day":
return formatConditional(format("%-d"), format("%b"), template);
- case "week":
- return formatConditional(format("%-d"), format("%b"), template);
case "month":
return formatConditional(format("%b"), format("%Y"), template);
case "year":
@@ -139,18 +213,25 @@ 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];
+function getTimeTemplate(anchor) {
+ return 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}`;
+}
+
+// Given an array of dates, returns the largest compatible standard time
+// interval. If no standard interval is compatible (other than milliseconds,
+// which is universally compatible), returns undefined.
+export function inferTimeFormat(dates, anchor) {
+ const step = max(pairs(dates, (a, b) => Math.abs(b - a))); // maybe undefined!
+ if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor);
+ for (const [name, interval, type, maxStep] of formatIntervals) {
+ if (step > maxStep) break; // e.g., 52 weeks
+ if (name === "hour" && !step) break; // e.g., domain with a single date
+ if (dates.every((d) => interval.floor(d) >= d)) return formatTimeInterval(name, type, anchor);
+ }
}
function formatConditional(format1, format2, template) {
diff --git a/src/transforms/bin.js b/src/transforms/bin.js
index f929abfedf..a33e9642d5 100644
--- a/src/transforms/bin.js
+++ b/src/transforms/bin.js
@@ -13,8 +13,10 @@ import {
coerceDate,
coerceNumbers,
identity,
+ isInterval,
isIterable,
isTemporal,
+ isTimeInterval,
labelof,
map,
maybeApplyInterval,
@@ -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) {
diff --git a/src/warnings.js b/src/warnings.js
index 6c13e8db1e..0b06df538c 100644
--- a/src/warnings.js
+++ b/src/warnings.js
@@ -1,12 +1,16 @@
let warnings = 0;
+let lastMessage;
export function consumeWarnings() {
const w = warnings;
warnings = 0;
+ lastMessage = undefined;
return w;
}
export function warn(message) {
+ if (message === lastMessage) return;
+ lastMessage = message;
console.warn(message);
++warnings;
}
diff --git a/test/output/autoBarTimeSeries.svg b/test/output/autoBarTimeSeries.svg
index 6a7dbd2ed1..b8ec70a104 100644
--- a/test/output/autoBarTimeSeries.svg
+++ b/test/output/autoBarTimeSeries.svg
@@ -51,15 +51,12 @@
- 2023-04-01
- 2023-04-05
- 2023-04-10
- 2023-04-15
- 2023-04-20
- 2023-04-25
-
-
- date
+ 1Apr
+ 5
+ 10
+ 15
+ 20
+ 25
diff --git a/test/output/bandClip2.svg b/test/output/bandClip2.svg
index 08d095600a..28d7962ade 100644
--- a/test/output/bandClip2.svg
+++ b/test/output/bandClip2.svg
@@ -72,12 +72,12 @@
- 2022-12-01
- 2022-12-02
- 2022-12-03
- 2022-12-04
- 2022-12-05
- 2022-12-06
+ 1Dec
+ 2
+ 3
+ 4
+ 5
+ 6
diff --git a/test/output/boxplotFacetInterval.svg b/test/output/boxplotFacetInterval.svg
index 054a9cf1e6..473440c09a 100644
--- a/test/output/boxplotFacetInterval.svg
+++ b/test/output/boxplotFacetInterval.svg
@@ -17,33 +17,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -52,33 +37,18 @@
2.2
-
- 2.1
-
2
-
- 1.9
-
1.8
-
- 1.7
-
1.6
-
- 1.5
-
1.4
-
- 1.3
-
1.2
diff --git a/test/output/boxplotFacetNegativeInterval.svg b/test/output/boxplotFacetNegativeInterval.svg
index 054a9cf1e6..473440c09a 100644
--- a/test/output/boxplotFacetNegativeInterval.svg
+++ b/test/output/boxplotFacetNegativeInterval.svg
@@ -17,33 +17,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -52,33 +37,18 @@
2.2
-
- 2.1
-
2
-
- 1.9
-
1.8
-
- 1.7
-
1.6
-
- 1.5
-
1.4
-
- 1.3
-
1.2
diff --git a/test/output/downloadsOrdinal.svg b/test/output/downloadsOrdinal.svg
index acdff086bb..ca2ae40de0 100644
--- a/test/output/downloadsOrdinal.svg
+++ b/test/output/downloadsOrdinal.svg
@@ -1,4 +1,4 @@
-
-
-
-
-
-
-
-
-
-
2002
- 2003
2004
- 2005
2006
- 2007
2008
- 2009
2010
- 2011
2012
- 2013
2014
- 2015
2016
- 2017
2018
- 2019
diff --git a/test/output/yearlyRequestsDate.svg b/test/output/yearlyRequestsDate.svg
new file mode 100644
index 0000000000..0e0d182b1e
--- /dev/null
+++ b/test/output/yearlyRequestsDate.svg
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 2
+ 4
+ 6
+ 8
+ 10
+ 12
+ 14
+ 16
+ 18
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 2002
+ 2004
+ 2006
+ 2008
+ 2010
+ 2012
+ 2014
+ 2016
+ 2018
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/output/yearlyRequestsLine.svg b/test/output/yearlyRequestsLine.svg
index 9804ba1259..5770119d14 100644
--- a/test/output/yearlyRequestsLine.svg
+++ b/test/output/yearlyRequestsLine.svg
@@ -41,43 +41,19 @@
-
-
-
-
-
-
-
-
-
-
-
-
2002
- 2003
- 2004
2005
- 2006
- 2007
2008
- 2009
- 2010
2011
- 2012
- 2013
2014
- 2015
- 2016
2017
- 2018
- 2019
diff --git a/test/plots/downloads-ordinal.ts b/test/plots/downloads-ordinal.ts
index 3cadf0715b..dd0f6657a9 100644
--- a/test/plots/downloads-ordinal.ts
+++ b/test/plots/downloads-ordinal.ts
@@ -6,13 +6,7 @@ export async function downloadsOrdinal() {
(d) => d.date.getUTCFullYear() === 2019 && d.date.getUTCMonth() <= 1 && d.downloads > 0
);
return Plot.plot({
- width: 960,
- marginBottom: 55,
- x: {
- interval: "day",
- tickRotate: -90,
- tickFormat: "%b %d"
- },
+ x: {interval: "day"},
marks: [
Plot.barY(downloads, {x: "date", y: "downloads", fill: "#ccc"}),
Plot.tickY(downloads, {x: "date", y: "downloads"}),
diff --git a/test/plots/fruit-sales-date.ts b/test/plots/fruit-sales-date.ts
index 0aabf16283..3961aa6ab9 100644
--- a/test/plots/fruit-sales-date.ts
+++ b/test/plots/fruit-sales-date.ts
@@ -13,3 +13,16 @@ export async function fruitSalesDate() {
]
});
}
+
+export async function fruitSalesSingleDate() {
+ const sales = (await d3.csv("data/fruit-sales.csv", d3.autoType)).slice(0, 3);
+ return Plot.plot({
+ x: {
+ type: "band" // treat dates as ordinal, not temporal
+ },
+ marks: [
+ Plot.barY(sales, Plot.stackY({x: "date", y: "units", fill: "fruit"})),
+ Plot.text(sales, Plot.stackY({x: "date", y: "units", text: "fruit"}))
+ ]
+ });
+}
diff --git a/test/plots/ibm-trading.ts b/test/plots/ibm-trading.ts
index 6eb507ae74..b276c930b2 100644
--- a/test/plots/ibm-trading.ts
+++ b/test/plots/ibm-trading.ts
@@ -4,12 +4,7 @@ import * as d3 from "d3";
export async function ibmTrading() {
const ibm = await d3.csv("data/ibm.csv", d3.autoType).then((data) => data.slice(-20));
return Plot.plot({
- marginBottom: 65,
- x: {
- interval: "day",
- tickRotate: -40,
- label: null
- },
+ x: {interval: "day"},
y: {
transform: (d) => d / 1e6,
label: "Volume (USD, millions)",
diff --git a/test/plots/integer-interval.ts b/test/plots/integer-interval.ts
index 25ecb32175..76de1c5c99 100644
--- a/test/plots/integer-interval.ts
+++ b/test/plots/integer-interval.ts
@@ -8,12 +8,8 @@ export async function integerInterval() {
[5, 12]
];
return Plot.plot({
- x: {
- interval: 1
- },
- y: {
- zero: true
- },
+ x: {interval: 1},
+ y: {zero: true},
marks: [Plot.line(requests)]
});
}
diff --git a/test/plots/sparse-cell.ts b/test/plots/sparse-cell.ts
index 68a8087417..ec9cbfd5c9 100644
--- a/test/plots/sparse-cell.ts
+++ b/test/plots/sparse-cell.ts
@@ -5,7 +5,7 @@ export async function sparseCell() {
const simpsons = d3.sort(await d3.csv("data/simpsons.csv", d3.autoType), (d) => d.number_in_series);
const data = [...simpsons.slice(0, 26), ...simpsons.slice(-10)];
return Plot.plot({
- grid: true,
+ grid: 20,
padding: 0.05,
x: {
label: "Episode",
diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts
index dcb19f7fb0..803d113b0d 100644
--- a/test/plots/time-axis.ts
+++ b/test/plots/time-axis.ts
@@ -79,6 +79,72 @@ export async function timeAxisRight() {
export async function timeAxisExplicitInterval() {
const aapl = await d3.csv("data/aapl.csv", d3.autoType);
return Plot.plot({
- marks: [Plot.ruleY([0]), Plot.axisX({ticks: "3 months"}), Plot.gridX(), Plot.line(aapl, {x: "Date", y: "Close"})]
+ x: {interval: "month"},
+ marks: [Plot.ruleY([0]), Plot.dot(aapl, {x: "Date", y: "Close"})]
+ });
+}
+
+export async function timeAxisExplicitNonstandardInterval() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "4 weeks"}, // does not align with months
+ marks: [Plot.ruleY([0]), Plot.dot(aapl, {x: "Date", y: "Close"})]
+ });
+}
+
+export async function timeAxisExplicitNonstandardIntervalTicks() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "4 weeks", grid: true, ticks: "year"}, // no years start on Sunday
+ marks: [Plot.ruleY([0]), Plot.dot(aapl, {x: "Date", y: "Close"})]
+ });
+}
+
+export async function timeAxisOrdinal() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "month"},
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
+ });
+}
+
+export async function warnTimeAxisOrdinalIncompatible() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "4 weeks", ticks: "year"}, // ⚠️ no years start on Sunday
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
+ });
+}
+
+export async function timeAxisOrdinalSparseTicks() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "4 weeks", ticks: "52 weeks"},
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
+ });
+}
+
+export async function timeAxisOrdinalSparseInterval() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "52 weeks"},
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
+ });
+}
+
+export async function timeAxisOrdinalTicks() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ return Plot.plot({
+ x: {interval: "month", ticks: "3 months"},
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
+ });
+}
+
+export async function warnTimeAxisOrdinalExplicitIncompatibleTicks() {
+ const aapl = await d3.csv("data/aapl.csv", d3.autoType);
+ const [start, stop] = d3.extent(aapl, (d) => d.Date);
+ return Plot.plot({
+ x: {interval: "4 weeks", ticks: d3.utcYear.range(start, stop)}, // ⚠️ no years start on Sunday
+ marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))]
});
}
diff --git a/test/plots/walmarts-decades.ts b/test/plots/walmarts-decades.ts
index 3bec23210f..51ee6e52bb 100644
--- a/test/plots/walmarts-decades.ts
+++ b/test/plots/walmarts-decades.ts
@@ -38,3 +38,31 @@ export async function walmartsDecades() {
]
});
}
+
+export async function walmartsAdditions() {
+ const [walmarts, statemesh] = await Promise.all([
+ d3.tsv("data/walmarts.tsv", d3.autoType),
+ d3.json("data/us-counties-10m.json").then((us) =>
+ mesh(us, {
+ type: "GeometryCollection",
+ geometries: us.objects.states.geometries.filter((d) => d.id !== "02" && d.id !== "15")
+ })
+ )
+ ]);
+ return Plot.plot({
+ width: 200,
+ projection: "albers-usa",
+ fy: {interval: "5 years", axis: "right", tickFormat: "%Y—", reverse: true},
+ marks: [
+ Plot.geo(statemesh, {strokeOpacity: 0.25}),
+ Plot.raster(walmarts, {
+ pixelSize: 1.5,
+ imageRendering: "pixelated",
+ fy: "date",
+ x: "longitude",
+ y: "latitude",
+ fill: "date"
+ })
+ ]
+ });
+}
diff --git a/test/plots/yearly-requests.ts b/test/plots/yearly-requests.ts
index 5fc05f5f7d..2a72b96a7d 100644
--- a/test/plots/yearly-requests.ts
+++ b/test/plots/yearly-requests.ts
@@ -1,26 +1,32 @@
import * as Plot from "@observablehq/plot";
+const requests = [
+ [new Date("2002-01-01"), 9],
+ [new Date("2003-01-01"), 17],
+ [new Date("2004-01-01"), 12],
+ [new Date("2005-01-01"), 5],
+ [new Date("2006-01-01"), 12],
+ [new Date("2007-01-01"), 18],
+ [new Date("2008-01-01"), 16],
+ [new Date("2009-01-01"), 11],
+ [new Date("2010-01-01"), 9],
+ [new Date("2011-01-01"), 8],
+ [new Date("2012-01-01"), 9],
+ [new Date("2019-01-01"), 20]
+];
+
export async function yearlyRequests() {
- const requests = [
- [2002, 9],
- [2003, 17],
- [2004, 12],
- [2005, 5],
- [2006, 12],
- [2007, 18],
- [2008, 16],
- [2009, 11],
- [2010, 9],
- [2011, 8],
- [2012, 9],
- [2019, 20]
- ];
return Plot.plot({
label: null,
- x: {
- interval: 1,
- tickFormat: "" // TODO https://github.com/observablehq/plot/issues/768
- },
+ x: {interval: 1, tickFormat: ""}, // TODO https://github.com/observablehq/plot/issues/768
+ marks: [Plot.barY(requests, {x: ([date]) => date.getUTCFullYear(), y: "1"})]
+ });
+}
+
+export async function yearlyRequestsDate() {
+ return Plot.plot({
+ label: null,
+ x: {interval: "year"},
marks: [Plot.barY(requests, {x: "0", y: "1"})]
});
}