From 3dcef2c3099f9ce978c9df14467dda19ecd92fff Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 4 Aug 2023 16:15:17 -0700 Subject: [PATCH 01/23] ordinal time axis --- src/marks/axis.js | 95 +++++++--- src/options.js | 8 + src/time.js | 75 ++++---- src/transforms/bin.js | 10 +- test/output/bandClip2.svg | 12 +- test/output/downloadsOrdinal.svg | 220 ++++++++--------------- test/output/ibmTrading.svg | 56 ++---- test/output/intervalAwareGroup.svg | 10 -- test/output/intervalAwareStack.svg | 10 -- test/output/penguinNA3.svg | 1 - test/output/stargazersHourly.svg | 1 + test/output/stargazersHourlyGroup.svg | 1 + test/output/timeAxisOrdinal.svg | 120 +++++++++++++ test/output/timeAxisOrdinalIrregular.svg | 109 +++++++++++ test/output/timeAxisOrdinalTicks.svg | 130 ++++++++++++++ test/output/yearlyRequestsDate.svg | 78 ++++++++ test/plots/downloads-ordinal.ts | 8 +- test/plots/ibm-trading.ts | 7 +- test/plots/integer-interval.ts | 8 +- test/plots/time-axis.ts | 24 +++ test/plots/yearly-requests.ts | 42 +++-- 21 files changed, 707 insertions(+), 318 deletions(-) create mode 100644 test/output/timeAxisOrdinal.svg create mode 100644 test/output/timeAxisOrdinalIrregular.svg create mode 100644 test/output/timeAxisOrdinalTicks.svg create mode 100644 test/output/yearlyRequestsDate.svg diff --git a/src/marks/axis.js b/src/marks/axis.js index 28c5180d06..9064dcf99c 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -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"; @@ -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,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); @@ -542,13 +544,30 @@ 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)]; @@ -556,7 +575,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,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 @@ -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; @@ -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)); } 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..1e4e9d72d2 100644 --- a/src/time.js +++ b/src/time.js @@ -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"; @@ -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([ @@ -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": @@ -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) { 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/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/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 @@ - + \ No newline at end of file diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg index acea2508f6..8275902e65 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -1,4 +1,4 @@ - + - - - - - - - - - - - - - - - 2018-04-16 - 2018-04-17 - 2018-04-18 - 2018-04-19 - 2018-04-20 - 2018-04-21 - 2018-04-22 - 2018-04-23 - 2018-04-24 - 2018-04-25 - 2018-04-26 - 2018-04-27 - 2018-04-28 - 2018-04-29 - 2018-04-30 - 2018-05-01 - 2018-05-02 - 2018-05-03 - 2018-05-04 - 2018-05-05 - 2018-05-06 - 2018-05-07 - 2018-05-08 - 2018-05-09 - 2018-05-10 - 2018-05-11 + + 17Apr + 19 + 21 + 23 + 25 + 27 + 29 + 1May + 3 + 5 + 7 + 9 + 11 diff --git a/test/output/intervalAwareGroup.svg b/test/output/intervalAwareGroup.svg index b3d2848ca0..b1ee14d218 100644 --- a/test/output/intervalAwareGroup.svg +++ b/test/output/intervalAwareGroup.svg @@ -42,28 +42,18 @@ - - - - - 1950 - 1955 1960 - 1965 1970 - 1975 1980 - 1985 1990 - 1995 2000 diff --git a/test/output/intervalAwareStack.svg b/test/output/intervalAwareStack.svg index 3f7f2d8c80..f19c5032d6 100644 --- a/test/output/intervalAwareStack.svg +++ b/test/output/intervalAwareStack.svg @@ -39,28 +39,18 @@ - - - - - 1950 - 1955 1960 - 1965 1970 - 1975 1980 - 1985 1990 - 1995 2000 diff --git a/test/output/penguinNA3.svg b/test/output/penguinNA3.svg index 288a036ea3..484cffe8df 100644 --- a/test/output/penguinNA3.svg +++ b/test/output/penguinNA3.svg @@ -24,7 +24,6 @@ - 3,000 3,500 4,000 diff --git a/test/output/stargazersHourly.svg b/test/output/stargazersHourly.svg index fcf3e6e5c4..8f1660a95b 100644 --- a/test/output/stargazersHourly.svg +++ b/test/output/stargazersHourly.svg @@ -88,6 +88,7 @@ 8 9 10+ + New stargazers per hour → diff --git a/test/output/stargazersHourlyGroup.svg b/test/output/stargazersHourlyGroup.svg index 04891e747f..2c1bff38a1 100644 --- a/test/output/stargazersHourlyGroup.svg +++ b/test/output/stargazersHourlyGroup.svg @@ -88,6 +88,7 @@ 8 9 10+ + New stargazers per hour → diff --git a/test/output/timeAxisOrdinal.svg b/test/output/timeAxisOrdinal.svg new file mode 100644 index 0000000000..91b46421dc --- /dev/null +++ b/test/output/timeAxisOrdinal.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + 2013-05-13 + 2013-06-03 + 2013-07-01 + 2013-08-01 + 2013-09-03 + 2013-10-01 + 2013-11-01 + 2013-12-02 + 2014-01-02 + 2014-02-03 + 2014-03-03 + 2014-04-01 + 2014-05-01 + 2014-06-02 + 2014-07-01 + 2014-08-01 + 2014-09-02 + 2014-10-01 + 2014-11-03 + 2014-12-01 + 2015-01-02 + 2015-02-02 + 2015-03-02 + 2015-04-01 + 2015-05-01 + 2015-06-01 + 2015-07-01 + 2015-08-03 + 2015-09-01 + 2015-10-01 + 2015-11-02 + 2015-12-01 + 2016-01-04 + 2016-02-01 + 2016-03-01 + 2016-04-01 + 2016-05-02 + 2016-06-01 + 2016-07-01 + 2016-08-01 + 2016-09-01 + 2016-10-03 + 2016-11-01 + 2016-12-01 + 2017-01-03 + 2017-02-01 + 2017-03-01 + 2017-04-03 + 2017-05-01 + 2017-06-01 + 2017-07-03 + 2017-08-01 + 2017-09-01 + 2017-10-02 + 2017-11-01 + 2017-12-01 + 2018-01-02 + 2018-02-01 + 2018-03-01 + 2018-04-02 + 2018-05-01 + + \ No newline at end of file diff --git a/test/output/timeAxisOrdinalIrregular.svg b/test/output/timeAxisOrdinalIrregular.svg new file mode 100644 index 0000000000..6a708e73ce --- /dev/null +++ b/test/output/timeAxisOrdinalIrregular.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + ↑ Close + + + 2013-05-13 + 2013-05-20 + 2013-06-17 + 2013-07-15 + 2013-08-12 + 2013-09-09 + 2013-10-07 + 2013-11-04 + 2013-12-02 + 2013-12-30 + 2014-01-27 + 2014-02-24 + 2014-03-24 + 2014-04-21 + 2014-05-19 + 2014-06-16 + 2014-07-14 + 2014-08-11 + 2014-09-08 + 2014-10-06 + 2014-11-03 + 2014-12-01 + 2014-12-29 + 2015-01-26 + 2015-02-23 + 2015-03-23 + 2015-04-20 + 2015-05-18 + 2015-06-15 + 2015-07-13 + 2015-08-10 + 2015-09-08 + 2015-10-05 + 2015-11-02 + 2015-11-30 + 2015-12-28 + 2016-01-25 + 2016-02-22 + 2016-03-21 + 2016-04-18 + 2016-05-16 + 2016-06-13 + 2016-07-11 + 2016-08-08 + 2016-09-06 + 2016-10-03 + 2016-10-31 + 2016-11-28 + 2016-12-27 + 2017-01-23 + 2017-02-21 + 2017-03-20 + 2017-04-17 + 2017-05-15 + 2017-06-12 + 2017-07-10 + 2017-08-07 + 2017-09-05 + 2017-10-02 + 2017-10-30 + 2017-11-27 + 2017-12-26 + 2018-01-22 + 2018-02-20 + 2018-03-19 + 2018-04-16 + + \ No newline at end of file diff --git a/test/output/timeAxisOrdinalTicks.svg b/test/output/timeAxisOrdinalTicks.svg new file mode 100644 index 0000000000..131cbf4b34 --- /dev/null +++ b/test/output/timeAxisOrdinalTicks.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + + + + + + Jul2013 + Jan2014 + Jul + Jan2015 + Jul + Jan2016 + Jul + Jan2017 + Jul + Jan2018 + + + 2013-05-13 + 2013-06-03 + 2013-07-01 + 2013-08-01 + 2013-09-03 + 2013-10-01 + 2013-11-01 + 2013-12-02 + 2014-01-02 + 2014-02-03 + 2014-03-03 + 2014-04-01 + 2014-05-01 + 2014-06-02 + 2014-07-01 + 2014-08-01 + 2014-09-02 + 2014-10-01 + 2014-11-03 + 2014-12-01 + 2015-01-02 + 2015-02-02 + 2015-03-02 + 2015-04-01 + 2015-05-01 + 2015-06-01 + 2015-07-01 + 2015-08-03 + 2015-09-01 + 2015-10-01 + 2015-11-02 + 2015-12-01 + 2016-01-04 + 2016-02-01 + 2016-03-01 + 2016-04-01 + 2016-05-02 + 2016-06-01 + 2016-07-01 + 2016-08-01 + 2016-09-01 + 2016-10-03 + 2016-11-01 + 2016-12-01 + 2017-01-03 + 2017-02-01 + 2017-03-01 + 2017-04-03 + 2017-05-01 + 2017-06-01 + 2017-07-03 + 2017-08-01 + 2017-09-01 + 2017-10-02 + 2017-11-01 + 2017-12-01 + 2018-01-02 + 2018-02-01 + 2018-03-01 + 2018-04-02 + 2018-05-01 + + \ No newline at end of file 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/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/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/time-axis.ts b/test/plots/time-axis.ts index dcb19f7fb0..6ef2268661 100644 --- a/test/plots/time-axis.ts +++ b/test/plots/time-axis.ts @@ -82,3 +82,27 @@ export async function timeAxisExplicitInterval() { marks: [Plot.ruleY([0]), Plot.axisX({ticks: "3 months"}), Plot.gridX(), Plot.line(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 timeAxisOrdinalIrregular() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + x: {interval: "4 weeks", ticks: "year"}, + 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"}))] + }); +} 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"})] }); } From d9eaa17b83999c0c2a2bfdbd962df315ecc22c4b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 16 Aug 2023 17:11:16 -0700 Subject: [PATCH 02/23] filter ordinal ticks with numeric intervals --- src/marks/axis.js | 6 +++++- test/output/intervalAwareBin.svg | 7 ------- test/output/sparseCell.svg | 14 -------------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 9064dcf99c..d649934abd 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -3,7 +3,7 @@ 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, isTimeInterval, orderof} from "../options.js"; +import {isIterable, isNoneish, isTemporal, isInterval, isTimeInterval, orderof} from "../options.js"; import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; import {isOrdinalScale, isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; @@ -567,6 +567,10 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const template = (f1, f2) => `${f1}\n${f2}`; // TODO based on anchor tickFormat = formatTimeInterval(i, format, template); } + } else if (isInterval(scale.interval) && tickFormat === undefined) { + if (ticks === undefined) ticks = inferTickCount(k, scale, options); + const n = Math.floor(getSkip(data, ticks)); + tickFormat = (d, i) => (i % n === 0 ? formatDefault(d) : null); } } if (k === "y" || k === "x") { diff --git a/test/output/intervalAwareBin.svg b/test/output/intervalAwareBin.svg index e7cfdef76f..812850d100 100644 --- a/test/output/intervalAwareBin.svg +++ b/test/output/intervalAwareBin.svg @@ -61,19 +61,12 @@ 30 - 40 50 - 60 70 - 80 90 - 100 110 - 120 130 - 140 150 - 160 170 diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg index 75a91cfb21..fc33580de7 100644 --- a/test/output/sparseCell.svg +++ b/test/output/sparseCell.svg @@ -156,26 +156,12 @@ 1 - 2 - 3 4 - 5 - 6 7 - 8 - 9 10 - 11 - 12 13 - 14 - 15 16 - 17 - 18 19 - 20 - 21 22 From d27eb7a73620e107cc964934da9edb783ee50399 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 10:04:43 -0700 Subject: [PATCH 03/23] checkpoint --- src/marks/axis.js | 58 ++++++++++------- src/time.js | 80 ++++++++++++++++-------- test/output/downloadsOrdinal.svg | 52 +++++++++++++-- test/output/ibmTrading.svg | 33 ++++++---- test/output/intervalAwareGroup.svg | 5 ++ test/output/intervalAwareStack.svg | 5 ++ test/output/sparseCell.svg | 14 ----- test/output/timeAxisOrdinal.svg | 69 ++++++++++++++++++-- test/output/timeAxisOrdinalIrregular.svg | 14 +++++ test/output/timeAxisOrdinalTicks.svg | 21 ++++--- test/output/yearlyRequestsDate.svg | 9 +++ 11 files changed, 267 insertions(+), 93 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index d649934abd..c8c189b00f 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,4 +1,4 @@ -import {extent, format, median, pairs, timeFormat, utcFormat} from "d3"; +import {extent, format, timeFormat, union, utcFormat} from "d3"; import {formatDefault} from "../format.js"; import {marks} from "../mark.js"; import {radians} from "../math.js"; @@ -7,7 +7,7 @@ import {isIterable, isNoneish, isTemporal, isInterval, isTimeInterval, orderof} import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; import {isOrdinalScale, isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; -import {formatTimeInterval, formatTimeTicks, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js"; +import {formatTimeTicks, inferTimeFormat2, isTimeYear, isUtcYear} from "../time.js"; import {initializer} from "../transforms/basic.js"; import {ruleX, ruleY} from "./rule.js"; import {text, textX, textY} from "./text.js"; @@ -550,27 +550,37 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { } } 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 (isInterval(scale.interval)) { + // If a ticks interval (the ticks option) is specified on an ordinal + // scale with an interval, we use the ticks interval to generate the + // ticks. However, the ticks interval may be incompatible with the scale + // interval, and if so, the time format inferred from the subsequent + // ticks may not be specific enough. For example, if the scale’s + // interval is "4 weeks" and the tick interval is "year", ticks are on + // Sunday near the beginning of each year; however, the "day" format + // (e.g., "Jan 26") derived from the "4 weeks" scale interval does not + // show the year, and hence is not a good choice for yearly ticks; hence + // we use the default format (2014-01-26) instead. + let compatible = true; + if (interval !== undefined) { + const [start, stop] = extent(data); + data = maybeRangeInterval(interval).range(start, +stop + 1); // inclusive stop + compatible = data.every((d) => scale.interval.floor(d) >= d); + if (!compatible) data = [...union(data.map(scale.interval.ceil, scale.interval))]; + } + // TODO We only need to compute the tickFormat for text; we don’t need + // this for rules and vectors. TODO We could also consider skipping + // ticks when the scale doesn’t have an associated interval? That + // loses information, but maybe it’s better than having overlapping + // ticks that are unreadable? 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); + let format = formatDefault; + if (isTimeInterval(scale.interval) && compatible) format = inferTimeFormat2(data, anchor); + if (ticks === undefined) ticks = inferTickCount(k, scale, options); + const n = Math.round(getSkip(data, ticks)); // TODO floor? + const j = 0; // TODO choose j to align with a standard time interval, if possible + tickFormat = n > 0 ? (d, i, D) => (i % n === j ? format(d, i, D, n) : null) : format; } - } else if (isInterval(scale.interval) && tickFormat === undefined) { - if (ticks === undefined) ticks = inferTickCount(k, scale, options); - const n = Math.floor(getSkip(data, ticks)); - tickFormat = (d, i) => (i % n === 0 ? formatDefault(d) : null); } } if (k === "y" || k === "x") { @@ -611,9 +621,9 @@ function getSkip(domain, 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 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; diff --git a/src/time.js b/src/time.js index 1e4e9d72d2..ab75bc6586 100644 --- a/src/time.js +++ b/src/time.js @@ -1,8 +1,9 @@ -import {bisector, extent, median, pairs, tickStep, timeFormat, utcFormat} from "d3"; +import {bisector, extent, median, pairs, tickStep, timeFormat, utcFormat, zip} 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"; import {timeMonday, timeTuesday, timeWednesday, timeThursday, timeFriday, timeSaturday, timeSunday} from "d3"; +import {formatDefault} from "./format.js"; import {orderof} from "./options.js"; const durationSecond = 1000; @@ -26,47 +27,60 @@ const formats = [ ["hour", "12 hours", 12 * durationHour], ["day", "1 day", durationDay], ["day", "2 days", 2 * durationDay], - ["week", "1 week", durationWeek], - ["week", "2 weeks", 2 * durationWeek], + ["day", "1 week", durationWeek], + ["day", "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 ]; +// Note: this must be in order from smallest to largest! const timeIntervals = new Map([ ["second", timeSecond], ["minute", timeMinute], ["hour", timeHour], ["day", timeDay], // TODO local time equivalent of unixDay? - ["week", timeWeek], - ["month", timeMonth], - ["year", timeYear], ["monday", timeMonday], ["tuesday", timeTuesday], ["wednesday", timeWednesday], ["thursday", timeThursday], ["friday", timeFriday], ["saturday", timeSaturday], - ["sunday", timeSunday] + ["sunday", timeSunday], + ["week", timeWeek], + ["month", timeMonth], + ["year", timeYear] ]); +// Note: this must be in order from smallest to largest! const utcIntervals = new Map([ ["second", utcSecond], ["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] ]); +// An interleaved array of UTC and local time intervals in order from largest to +// smallest; used by inferTimeInterval below, which is used to determine the +// most specific standard time interval for a given array of dates. Note that +// this does not consider skip intervals such as 2 days, 3 weeks, or 6 months. +const descendingIntervals = zip( + Array.from(utcIntervals, ([name, interval]) => [name, interval, "utc"]), + Array.from(timeIntervals, ([name, interval]) => [name, interval, "time"]) +) + .flat(1) + .reverse(); + function parseInterval(input, intervals) { let name = `${input}`.toLowerCase(); if (name.endsWith("s")) name = name.slice(0, -1); // drop plural @@ -125,18 +139,12 @@ export function formatTimeTicks(scale, data, ticks, anchor) { 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}` - ); + return formatTimeInterval(inferTimeFormat(step)[0], scale.type, anchor); } -export function formatTimeInterval(interval, format, template) { +function formatTimeInterval(interval, type, anchor) { + const format = type === "time" ? timeFormat : utcFormat; + const template = getTimeTemplate(anchor); switch (interval) { case "millisecond": return formatConditional(format(".%L"), format(":%M:%S"), template); @@ -148,8 +156,6 @@ export function formatTimeInterval(interval, format, template) { 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": @@ -158,6 +164,14 @@ export function formatTimeInterval(interval, format, template) { throw new Error("unable to format time ticks"); } +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}`; +} + // 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 @@ -168,11 +182,27 @@ export function inferTimeFormat(s) { return formats[bisector(([, , step]) => Math.log(step)).center(formats, Math.log(s))]; } +// 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 inferTimeFormat2(dates, anchor) { + for (const [name, interval, type] of descendingIntervals) { + if (dates.every((d) => interval.floor(d) >= d)) { + return formatTimeInterval(name, type, anchor); + } + } + return formatDefault; +} + +// TODO This assumes that the format is called sequentially, but if we filter +// the format by wrapping it then it does not behave as desired. We probably +// want to make the format stateful, or we need to pass in a skip value here… function formatConditional(format1, format2, template) { - return (x, i, X) => { + // TODO n is messy, don’t do this + return (x, i, X, n = 1) => { const f1 = format1(x, i); // always shown const f2 = format2(x, i); // only shown if different - const j = i - orderof(X); // detect reversed domains + const j = i - orderof(X) * n; // detect reversed domains return i !== j && X[j] !== undefined && f2 === format2(X[j], j) ? f1 : template(f1, f2); }; } diff --git a/test/output/downloadsOrdinal.svg b/test/output/downloadsOrdinal.svg index ca2ae40de0..58894d4acc 100644 --- a/test/output/downloadsOrdinal.svg +++ b/test/output/downloadsOrdinal.svg @@ -45,20 +45,62 @@ ↑ downloads + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 6Jan + 1Jan + 7 13 - 20 - 27 - 3Feb - 10 + 19 + 25 + 31 + 6Feb + 12 diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg index 8275902e65..40972300a1 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -56,34 +56,41 @@ ↑ Volume (USD, millions) + + + + + + + + + + + + + - 17Apr - 19 - 21 - 23 - 25 - 27 - 29 - 1May - 3 - 5 - 7 - 9 - 11 + 16Apr + 20 + 24 + 28 + 2May + 6 + 10 diff --git a/test/output/intervalAwareGroup.svg b/test/output/intervalAwareGroup.svg index b1ee14d218..6e9326e640 100644 --- a/test/output/intervalAwareGroup.svg +++ b/test/output/intervalAwareGroup.svg @@ -42,10 +42,15 @@ + + + + + diff --git a/test/output/intervalAwareStack.svg b/test/output/intervalAwareStack.svg index f19c5032d6..76ede45272 100644 --- a/test/output/intervalAwareStack.svg +++ b/test/output/intervalAwareStack.svg @@ -39,10 +39,15 @@ + + + + + diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg index fc33580de7..b6d6d6cddb 100644 --- a/test/output/sparseCell.svg +++ b/test/output/sparseCell.svg @@ -75,33 +75,19 @@ 1 - 2 3 - 4 5 - 6 7 - 8 9 - 10 11 - 12 13 - 14 15 - 16 17 - 18 19 - 20 21 - 22 23 - 24 25 - 26 27 - 28 ← Season diff --git a/test/output/timeAxisOrdinal.svg b/test/output/timeAxisOrdinal.svg index 91b46421dc..c5de6caf45 100644 --- a/test/output/timeAxisOrdinal.svg +++ b/test/output/timeAxisOrdinal.svg @@ -41,18 +41,77 @@ ↑ Close + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 2014 - 2015 - 2016 - 2017 - 2018 + May2013 + Jan2014 + Sep + May2015 + Jan2016 + Sep + May2017 + Jan2018 2013-05-13 diff --git a/test/output/timeAxisOrdinalIrregular.svg b/test/output/timeAxisOrdinalIrregular.svg index 6a708e73ce..88187c766c 100644 --- a/test/output/timeAxisOrdinalIrregular.svg +++ b/test/output/timeAxisOrdinalIrregular.svg @@ -38,6 +38,20 @@ ↑ Close + + + + + + + + + 2014-01-26 + 2015-01-25 + 2016-01-24 + 2017-01-22 + 2018-01-21 + 2013-05-13 2013-05-20 diff --git a/test/output/timeAxisOrdinalTicks.svg b/test/output/timeAxisOrdinalTicks.svg index 131cbf4b34..2da870e536 100644 --- a/test/output/timeAxisOrdinalTicks.svg +++ b/test/output/timeAxisOrdinalTicks.svg @@ -42,26 +42,33 @@ + + + + + + + + + + Jul2013 - Jan2014 - Jul + Apr2014 Jan2015 - Jul - Jan2016 - Jul - Jan2017 - Jul + Oct + Jul2016 + Apr2017 Jan2018 diff --git a/test/output/yearlyRequestsDate.svg b/test/output/yearlyRequestsDate.svg index 0e0d182b1e..8eb4ba4f24 100644 --- a/test/output/yearlyRequestsDate.svg +++ b/test/output/yearlyRequestsDate.svg @@ -41,14 +41,23 @@ + + + + + + + + + 2002 From 102eb06184d3ff66734cfd8904ff6ec915bd9310 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 12:40:30 -0700 Subject: [PATCH 04/23] simplify hasTimeTicks --- src/marks/axis.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index c8c189b00f..2063030b78 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -5,7 +5,7 @@ import {radians} from "../math.js"; import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js"; import {isIterable, isNoneish, isTemporal, isInterval, isTimeInterval, orderof} from "../options.js"; import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; -import {isOrdinalScale, isTemporalScale} from "../scales.js"; +import {isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; import {formatTimeTicks, inferTimeFormat2, isTimeYear, isUtcYear} from "../time.js"; import {initializer} from "../transforms/basic.js"; @@ -551,16 +551,16 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { } else { data = scale.domain(); if (isInterval(scale.interval)) { - // If a ticks interval (the ticks option) is specified on an ordinal - // scale with an interval, we use the ticks interval to generate the - // ticks. However, the ticks interval may be incompatible with the scale - // interval, and if so, the time format inferred from the subsequent - // ticks may not be specific enough. For example, if the scale’s - // interval is "4 weeks" and the tick interval is "year", ticks are on - // Sunday near the beginning of each year; however, the "day" format - // (e.g., "Jan 26") derived from the "4 weeks" scale interval does not - // show the year, and hence is not a good choice for yearly ticks; hence - // we use the default format (2014-01-26) instead. + // If a tick interval (the ticks option) is specified on an ordinal + // scale with an interval, we use the tick interval to generate the + // ticks. However, the tick interval may be incompatible with the + // scale interval, and if so, the time format inferred from the + // subsequent ticks may not be specific enough. For example, if the + // scale’s interval is "4 weeks" and the tick interval is "year", + // ticks are on Sunday near the beginning of each year; however, the + // "day" format (e.g., "Jan 26") derived from the "4 weeks" scale + // interval does not show the year, and hence is not a good choice for + // yearly ticks; hence we use the default format (2014-01-26) instead. let compatible = true; if (interval !== undefined) { const [start, stop] = extent(data); @@ -744,5 +744,5 @@ function maybeLabelArrow(labelArrow = "auto") { } function hasTimeTicks(scale) { - return isTemporalScale(scale) || (isOrdinalScale(scale) && isTimeInterval(scale.interval)); + return isTemporalScale(scale) || isTimeInterval(scale.interval); } From aa92e48a58d123d225e113804e2f60f5c9238eee Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 12:44:01 -0700 Subject: [PATCH 05/23] fix nullish check --- src/marks/axis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 2063030b78..910a50e986 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -562,7 +562,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // interval does not show the year, and hence is not a good choice for // yearly ticks; hence we use the default format (2014-01-26) instead. let compatible = true; - if (interval !== undefined) { + if (interval != null) { const [start, stop] = extent(data); data = maybeRangeInterval(interval).range(start, +stop + 1); // inclusive stop compatible = data.every((d) => scale.interval.floor(d) >= d); From 6bc8539933b51b97ff0b0a295b09d66994e98248 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 13:20:49 -0700 Subject: [PATCH 06/23] filter approach --- src/marks/axis.js | 44 ++++++++++----------- src/time.js | 49 ++++++++++-------------- test/output/timeAxisOrdinalIrregular.svg | 2 + test/output/timeAxisOrdinalTicks.svg | 15 ++++---- 4 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 910a50e986..ad4a2439ef 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,4 +1,4 @@ -import {extent, format, timeFormat, union, utcFormat} from "d3"; +import {extent, format, timeFormat, utcFormat} from "d3"; import {formatDefault} from "../format.js"; import {marks} from "../mark.js"; import {radians} from "../math.js"; @@ -552,30 +552,30 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { data = scale.domain(); if (isInterval(scale.interval)) { // If a tick interval (the ticks option) is specified on an ordinal - // scale with an interval, we use the tick interval to generate the - // ticks. However, the tick interval may be incompatible with the - // scale interval, and if so, the time format inferred from the - // subsequent ticks may not be specific enough. For example, if the - // scale’s interval is "4 weeks" and the tick interval is "year", - // ticks are on Sunday near the beginning of each year; however, the - // "day" format (e.g., "Jan 26") derived from the "4 weeks" scale - // interval does not show the year, and hence is not a good choice for - // yearly ticks; hence we use the default format (2014-01-26) instead. + // scale with an interval, use it to generate ticks. let compatible = true; - if (interval != null) { - const [start, stop] = extent(data); - data = maybeRangeInterval(interval).range(start, +stop + 1); // inclusive stop - compatible = data.every((d) => scale.interval.floor(d) >= d); - if (!compatible) data = [...union(data.map(scale.interval.ceil, scale.interval))]; + interval = maybeRangeInterval(interval); + if (interval) { + // The tick interval may be incompatible with the scale interval; + // for example, if the scale’s interval is "4 weeks" and the tick + // interval is "year", years are not aligned with 4-week intervals. + // So, rather than generate new ticks using the tick interval, we + // filter the existing ticks to include only the first tick in each + // interval. Also, the time format for the tick interval may not be + // specific enough: for example, the "day" format (e.g., Jan 26) for + // the "4 weeks" interval does not show the year; hence we use the + // default format (2014-01-26) instead. + data = data.filter((d, i) => { + const e = interval.floor(d); + if (scale.interval.floor(e) < e) compatible = false; + return i === 0 || e > interval.floor(data[i - 1]); + }); } - // TODO We only need to compute the tickFormat for text; we don’t need - // this for rules and vectors. TODO We could also consider skipping - // ticks when the scale doesn’t have an associated interval? That - // loses information, but maybe it’s better than having overlapping - // ticks that are unreadable? - if (tickFormat === undefined) { + // If the scale has an interval, generate a default tick format that + // drops ticks to avoid overlapping labels. + if ("text" in options && tickFormat === undefined) { let format = formatDefault; - if (isTimeInterval(scale.interval) && compatible) format = inferTimeFormat2(data, anchor); + if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat2(data, anchor); if (ticks === undefined) ticks = inferTickCount(k, scale, options); const n = Math.round(getSkip(data, ticks)); // TODO floor? const j = 0; // TODO choose j to align with a standard time interval, if possible diff --git a/src/time.js b/src/time.js index ab75bc6586..3d68760b41 100644 --- a/src/time.js +++ b/src/time.js @@ -1,4 +1,4 @@ -import {bisector, extent, median, pairs, tickStep, timeFormat, utcFormat, zip} from "d3"; +import {bisector, extent, median, pairs, timeFormat, utcFormat, zip} 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"; @@ -13,25 +13,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 = [ - ["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], - ["day", "1 week", durationWeek], - ["day", "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 + ["millisecond", 500], + ["second", durationSecond], + ["second", 30 * durationSecond], + ["minute", durationMinute], + ["minute", 30 * durationMinute], + ["hour", durationHour], + ["hour", 12 * durationHour], + ["day", durationDay], + ["day", 2 * durationDay], + ["day", durationWeek], + ["day", 2 * durationWeek], // new! + ["month", durationMonth], + ["month", 3 * durationMonth], + ["month", 6 * durationMonth], // new! https://github.com/d3/d3-time/issues/46 + ["year", durationYear] ]; // Note: this must be in order from smallest to largest! @@ -139,13 +139,14 @@ export function formatTimeTicks(scale, data, ticks, anchor) { const count = typeof ticks === "number" ? ticks : 10; step = Math.abs(stop - start) / count; } - return formatTimeInterval(inferTimeFormat(step)[0], scale.type, anchor); + const [name] = formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))]; + return formatTimeInterval(name, scale.type, anchor); } -function formatTimeInterval(interval, type, anchor) { +function formatTimeInterval(name, type, anchor) { const format = type === "time" ? timeFormat : utcFormat; const template = getTimeTemplate(anchor); - switch (interval) { + switch (name) { case "millisecond": return formatConditional(format(".%L"), format(":%M:%S"), template); case "second": @@ -172,16 +173,6 @@ function getTimeTemplate(anchor) { : (f1, f2) => `${f1}\n${f2}`; } -// 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))]; -} - // 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. diff --git a/test/output/timeAxisOrdinalIrregular.svg b/test/output/timeAxisOrdinalIrregular.svg index 88187c766c..15f420e0c0 100644 --- a/test/output/timeAxisOrdinalIrregular.svg +++ b/test/output/timeAxisOrdinalIrregular.svg @@ -39,6 +39,7 @@ ↑ Close + @@ -46,6 +47,7 @@ + 2013-04-21 2014-01-26 2015-01-25 2016-01-24 diff --git a/test/output/timeAxisOrdinalTicks.svg b/test/output/timeAxisOrdinalTicks.svg index 2da870e536..a7fa4f6b1f 100644 --- a/test/output/timeAxisOrdinalTicks.svg +++ b/test/output/timeAxisOrdinalTicks.svg @@ -41,6 +41,7 @@ ↑ Close + @@ -63,13 +64,13 @@ - Jul2013 - Apr2014 - Jan2015 - Oct - Jul2016 - Apr2017 - Jan2018 + May2013 + Jan2014 + Oct + Jul2015 + Apr2016 + Jan2017 + Oct 2013-05-13 From 765a7c30181782a33c3df9ab49676aff2df730a2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 13:22:17 -0700 Subject: [PATCH 07/23] inferTimeFormat --- src/marks/axis.js | 4 ++-- src/time.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index ad4a2439ef..d61d1f6c17 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -7,7 +7,7 @@ import {isIterable, isNoneish, isTemporal, isInterval, isTimeInterval, orderof} import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; import {isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; -import {formatTimeTicks, inferTimeFormat2, isTimeYear, isUtcYear} from "../time.js"; +import {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"; @@ -575,7 +575,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // drops ticks to avoid overlapping labels. if ("text" in options && tickFormat === undefined) { let format = formatDefault; - if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat2(data, anchor); + if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); if (ticks === undefined) ticks = inferTickCount(k, scale, options); const n = Math.round(getSkip(data, ticks)); // TODO floor? const j = 0; // TODO choose j to align with a standard time interval, if possible diff --git a/src/time.js b/src/time.js index 3d68760b41..cef3380b3e 100644 --- a/src/time.js +++ b/src/time.js @@ -176,7 +176,7 @@ function getTimeTemplate(anchor) { // 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 inferTimeFormat2(dates, anchor) { +export function inferTimeFormat(dates, anchor) { for (const [name, interval, type] of descendingIntervals) { if (dates.every((d) => interval.floor(d) >= d)) { return formatTimeInterval(name, type, anchor); From 37e322082bd747ce25422be886b23d87b7bf058d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 13:31:57 -0700 Subject: [PATCH 08/23] tidy --- src/marks/axis.js | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index d61d1f6c17..472ac11e8a 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -525,9 +525,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const {[k]: scale} = scales; if (!scale) throw new Error(`missing scale: ${k}`); 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 (hasTimeTicks(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined); // TODO allow ticks as interval implementation if (data == null) { if (isIterable(ticks)) { data = arrayify(ticks); @@ -577,9 +575,11 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { let format = formatDefault; if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); if (ticks === undefined) ticks = inferTickCount(k, scale, options); - const n = Math.round(getSkip(data, ticks)); // TODO floor? - const j = 0; // TODO choose j to align with a standard time interval, if possible - tickFormat = n > 0 ? (d, i, D) => (i % n === j ? format(d, i, D, n) : null) : format; + // 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); + tickFormat = n > 0 ? (d, i, D) => (i % n === 0 ? format(d, i, D, n) : null) : format; } } } @@ -613,30 +613,12 @@ function axisMark(mark, k, anchor, 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: typeof tickFormat === "function" ? tickFormat : inferTickFormat(scale, data, ticks, tickFormat, anchor) From 3478c8f4fae0081ba151d30eb9e5289af09da1a0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 13:35:59 -0700 Subject: [PATCH 09/23] prune redundant formats --- src/time.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/time.js b/src/time.js index cef3380b3e..140ef798c9 100644 --- a/src/time.js +++ b/src/time.js @@ -14,7 +14,6 @@ const durationWeek = durationDay * 7; const durationMonth = durationDay * 30; const durationYear = durationDay * 365; -// [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", 500], @@ -25,11 +24,8 @@ const formats = [ ["hour", durationHour], ["hour", 12 * durationHour], ["day", durationDay], - ["day", 2 * durationDay], - ["day", durationWeek], ["day", 2 * durationWeek], // new! ["month", durationMonth], - ["month", 3 * durationMonth], ["month", 6 * durationMonth], // new! https://github.com/d3/d3-time/issues/46 ["year", durationYear] ]; From 765945682915be69c6375d064306f1612a5e6859 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 14:58:18 -0700 Subject: [PATCH 10/23] tidy --- src/marks/axis.js | 94 +++++++++++++++------------- src/time.js | 28 +++++---- test/output/timeAxisOrdinalTicks.svg | 18 +++++- test/output/yearlyRequestsLine.svg | 18 ------ 4 files changed, 84 insertions(+), 74 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 472ac11e8a..5b0e3118f5 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -524,60 +524,71 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const initializeFacets = data == null && (k === "fx" || k === "fy"); const {[k]: scale} = scales; if (!scale) throw new Error(`missing scale: ${k}`); - let {ticks, tickFormat, interval} = options; - if (hasTimeTicks(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined); // TODO allow ticks as interval implementation + // 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!) + let { + interval, + ticks = maybeRangeInterval(interval, scale.type), + tickFormat, + tickSpacing = k === "x" ? 80 : 35 + } = options; + // For a time scale, or any scale with a time interval, 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" && hasTimeTicks(scale)) ticks = maybeRangeInterval(ticks, scale.type); + // Lastly use the tickSpacing option to infer the desired tick count. + if (ticks == undefined) ticks = 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); + // For continuous scales, use the specified tick interval, if any. 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. + if (isInterval(ticks)) { + const [min, max] = extent(scale.domain()); + data = ticks.range(min, ticks.offset(ticks.floor(max))); // inclusive 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 { - ticks = inferTickCount(k, scale, options); - data = scale.ticks(ticks); - } + data = scale.ticks(ticks); } + // Remove any ticks that aren’t compatible with the scale interval. + if (scale.interval) data = data.filter((d) => scale.interval.floor(d) >= d); } else { data = scale.domain(); - if (isInterval(scale.interval)) { - // If a tick interval (the ticks option) is specified on an ordinal - // scale with an interval, use it to generate ticks. + if (scale.interval) { + // For ordinal scales with an interval, use the specified tick + // interval, if any. Note that the tick interval and scale interval + // may be incompatible; for example, if the scale interval is "4 + // weeks" and the tick interval is "year", years are not aligned with + // 4-week intervals. So, rather than generate ticks using the tick + // interval, we filter the existing ticks to include only the first + // tick in each interval. Also, the time format for the tick interval + // may not be specific enough: for example, the "day" format (e.g., + // Jan 26) for the "4 weeks" interval does not show the year; hence we + // use the default format (2014-01-26) instead. let compatible = true; - interval = maybeRangeInterval(interval); - if (interval) { - // The tick interval may be incompatible with the scale interval; - // for example, if the scale’s interval is "4 weeks" and the tick - // interval is "year", years are not aligned with 4-week intervals. - // So, rather than generate new ticks using the tick interval, we - // filter the existing ticks to include only the first tick in each - // interval. Also, the time format for the tick interval may not be - // specific enough: for example, the "day" format (e.g., Jan 26) for - // the "4 weeks" interval does not show the year; hence we use the - // default format (2014-01-26) instead. + if (isInterval(ticks)) { data = data.filter((d, i) => { - const e = interval.floor(d); + const e = ticks.floor(d); if (scale.interval.floor(e) < e) compatible = false; - return i === 0 || e > interval.floor(data[i - 1]); + return i === 0 || e > ticks.floor(data[i - 1]); }); } - // If the scale has an interval, generate a default tick format that - // drops ticks to avoid overlapping labels. + // Now generate a default tick format that drops ticks to avoid + // overlapping labels. TODO We should have a flag that tests whether a + // given interval is UTC or local, or an option that lets the user + // indicate which format is desired… if ("text" in options && tickFormat === undefined) { let format = formatDefault; - if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); - if (ticks === undefined) ticks = inferTickCount(k, scale, options); - // 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. + if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); // TODO consider scale.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); tickFormat = n > 0 ? (d, i, D) => (i % n === 0 ? format(d, i, D, n) : null) : format; } @@ -613,8 +624,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { return m; } -function inferTickCount(k, scale, options) { - const {tickSpacing = k === "x" ? 80 : 35} = options; +function inferTickCount(scale, tickSpacing) { const [min, max] = extent(scale.range()); return (max - min) / tickSpacing; } @@ -632,7 +642,7 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { return tickFormat === undefined && isTemporalScale(scale) ? formatTimeTicks(scale, data, ticks, anchor) : scale.tickFormat - ? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat) + ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : tickFormat === undefined ? isUtcYear(scale.interval) ? utcFormat("%Y") diff --git a/src/time.js b/src/time.js index 140ef798c9..8f32197440 100644 --- a/src/time.js +++ b/src/time.js @@ -1,4 +1,4 @@ -import {bisector, extent, median, pairs, timeFormat, utcFormat, zip} from "d3"; +import {bisector, extent, median, 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"; @@ -30,7 +30,6 @@ const formats = [ ["year", durationYear] ]; -// Note: this must be in order from smallest to largest! const timeIntervals = new Map([ ["second", timeSecond], ["minute", timeMinute], @@ -48,7 +47,6 @@ const timeIntervals = new Map([ ["year", timeYear] ]); -// Note: this must be in order from smallest to largest! const utcIntervals = new Map([ ["second", utcSecond], ["minute", utcMinute], @@ -67,15 +65,21 @@ const utcIntervals = new Map([ ]); // An interleaved array of UTC and local time intervals in order from largest to -// smallest; used by inferTimeInterval below, which is used to determine the -// most specific standard time interval for a given array of dates. Note that -// this does not consider skip intervals such as 2 days, 3 weeks, or 6 months. -const descendingIntervals = zip( - Array.from(utcIntervals, ([name, interval]) => [name, interval, "utc"]), - Array.from(timeIntervals, ([name, interval]) => [name, interval, "time"]) -) - .flat(1) - .reverse(); +// smallest, used to determine the most specific standard time format for a +// given array of dates. +const descendingIntervals = [ + ["year", utcYear, "utc"], + ["year", timeYear, "time"], + ["month", utcMonth, "utc"], + ["month", timeMonth, "time"], + ["day", unixDay, "utc"], + ["day", timeDay, "time"], + // Below day, local time typically has an hourly offset from UTC and hence the + // two are aligned and indistinguishable; therefore, we only consider UTC. + ["hour", utcHour, "utc"], + ["minute", utcMinute, "utc"], + ["second", utcSecond, "utc"] +]; function parseInterval(input, intervals) { let name = `${input}`.toLowerCase(); diff --git a/test/output/timeAxisOrdinalTicks.svg b/test/output/timeAxisOrdinalTicks.svg index a7fa4f6b1f..51363105ac 100644 --- a/test/output/timeAxisOrdinalTicks.svg +++ b/test/output/timeAxisOrdinalTicks.svg @@ -65,12 +65,26 @@ May2013 + Jul + Oct Jan2014 + Apr + Jul Oct - Jul2015 - Apr2016 + Jan2015 + Apr + Jul + Oct + Jan2016 + Apr + Jul + Oct Jan2017 + Apr + Jul Oct + Jan2018 + Apr 2013-05-13 diff --git a/test/output/yearlyRequestsLine.svg b/test/output/yearlyRequestsLine.svg index 9804ba1259..139168d1ac 100644 --- a/test/output/yearlyRequestsLine.svg +++ b/test/output/yearlyRequestsLine.svg @@ -41,43 +41,25 @@ - - - - - - - - - 2002 - 2003 2004 - 2005 2006 - 2007 2008 - 2009 2010 - 2011 2012 - 2013 2014 - 2015 2016 - 2017 2018 - 2019 From fb69df815ab6ee1f6e73e46dfbd49c6cfe1f71e2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 15:06:33 -0700 Subject: [PATCH 11/23] comment --- src/marks/axis.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 5b0e3118f5..d0c6932464 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -579,12 +579,13 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { }); } // Now generate a default tick format that drops ticks to avoid - // overlapping labels. TODO We should have a flag that tests whether a - // given interval is UTC or local, or an option that lets the user - // indicate which format is desired… + // overlapping labels. if ("text" in options && tickFormat === undefined) { let format = formatDefault; - if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); // TODO consider scale.interval + // If the tick and scale intervals are compatible, we can use the + // default multi-line time format. TODO We need a better way to + // infer whether the ordinal scale is UTC or local time. + if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); // 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 From cdc77ad9a319afe9b8ad99c2b6e4ed4c56f8c9c0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 15:38:24 -0700 Subject: [PATCH 12/23] filter ticks, not just text --- src/marks/axis.js | 45 ++++++++-------- test/output/boxplotFacetInterval.svg | 30 ----------- test/output/boxplotFacetNegativeInterval.svg | 30 ----------- test/output/downloadsOrdinal.svg | 38 ------------- test/output/ibmTrading.svg | 19 ------- test/output/intervalAwareBin.svg | 7 --- test/output/intervalAwareGroup.svg | 5 -- test/output/intervalAwareStack.svg | 5 -- test/output/sparseCell.svg | 56 -------------------- test/output/timeAxisOrdinal.svg | 53 ------------------ test/output/yearlyRequests.svg | 18 ------- test/output/yearlyRequestsDate.svg | 9 ---- 12 files changed, 21 insertions(+), 294 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index d0c6932464..627cd42d14 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -545,11 +545,11 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // Use explicit ticks, if specified. data = arrayify(ticks); } else if (scale.ticks) { - // For continuous scales, use the specified tick interval, if any. 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. if (isInterval(ticks)) { + // For continuous scales, use the specified tick interval, if any. 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. const [min, max] = extent(scale.domain()); data = ticks.range(min, ticks.offset(ticks.floor(max))); // inclusive max } else { @@ -560,38 +560,35 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { } else { data = scale.domain(); if (scale.interval) { - // For ordinal scales with an interval, use the specified tick - // interval, if any. Note that the tick interval and scale interval - // may be incompatible; for example, if the scale interval is "4 - // weeks" and the tick interval is "year", years are not aligned with - // 4-week intervals. So, rather than generate ticks using the tick - // interval, we filter the existing ticks to include only the first - // tick in each interval. Also, the time format for the tick interval - // may not be specific enough: for example, the "day" format (e.g., - // Jan 26) for the "4 weeks" interval does not show the year; hence we - // use the default format (2014-01-26) instead. let compatible = true; if (isInterval(ticks)) { + // For ordinal scales with an interval, use the specified tick + // interval, if any. Note that the tick interval and scale interval + // may be incompatible; for example, if the scale interval is "4 + // weeks" and the tick interval is "year", years are not aligned + // with 4-week intervals. So, rather than generate ticks using the + // tick interval, we filter the existing ticks to include only the + // first tick in each interval. data = data.filter((d, i) => { const e = ticks.floor(d); if (scale.interval.floor(e) < e) compatible = false; return i === 0 || e > ticks.floor(data[i - 1]); }); - } - // Now generate a default tick format that drops ticks to avoid - // overlapping labels. - if ("text" in options && tickFormat === undefined) { - let format = formatDefault; - // If the tick and scale intervals are compatible, we can use the - // default multi-line time format. TODO We need a better way to - // infer whether the ordinal scale is UTC or local time. - if (compatible && isTimeInterval(scale.interval)) format = inferTimeFormat(data, anchor); + } else { // 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); - tickFormat = n > 0 ? (d, i, D) => (i % n === 0 ? format(d, i, D, n) : null) : format; + if (n > 0) data = data.filter((d, i) => i % n === 0); + } + // If the tick and scale time intervals are incompatible, we can’t use + // the multi-line time format. For example, the "day" format (e.g., + // Jan 26) for the "4 weeks" interval does not show the year; hence we + // use the default format (2014-01-26) instead. TODO We need a better + // way to infer whether the ordinal scale is UTC or local time. + if ("text" in options && tickFormat === undefined && compatible && isTimeInterval(scale.interval)) { + tickFormat = inferTimeFormat(data, anchor); } } } 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 58894d4acc..49960cc270 100644 --- a/test/output/downloadsOrdinal.svg +++ b/test/output/downloadsOrdinal.svg @@ -46,51 +46,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1Jan diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg index 40972300a1..d986a2fd86 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -57,31 +57,12 @@ - - - - - - - - - - - - - - - - - - - 16Apr diff --git a/test/output/intervalAwareBin.svg b/test/output/intervalAwareBin.svg index 812850d100..2edc29437c 100644 --- a/test/output/intervalAwareBin.svg +++ b/test/output/intervalAwareBin.svg @@ -44,19 +44,12 @@ - - - - - - - diff --git a/test/output/intervalAwareGroup.svg b/test/output/intervalAwareGroup.svg index 6e9326e640..b1ee14d218 100644 --- a/test/output/intervalAwareGroup.svg +++ b/test/output/intervalAwareGroup.svg @@ -42,15 +42,10 @@ - - - - - diff --git a/test/output/intervalAwareStack.svg b/test/output/intervalAwareStack.svg index 76ede45272..f19c5032d6 100644 --- a/test/output/intervalAwareStack.svg +++ b/test/output/intervalAwareStack.svg @@ -39,15 +39,10 @@ - - - - - diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg index b6d6d6cddb..a23df01596 100644 --- a/test/output/sparseCell.svg +++ b/test/output/sparseCell.svg @@ -15,63 +15,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 @@ -94,50 +66,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/output/timeAxisOrdinal.svg b/test/output/timeAxisOrdinal.svg index c5de6caf45..b549b9d532 100644 --- a/test/output/timeAxisOrdinal.svg +++ b/test/output/timeAxisOrdinal.svg @@ -42,66 +42,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - May2013 diff --git a/test/output/yearlyRequests.svg b/test/output/yearlyRequests.svg index 5ed80fd36d..0e0d182b1e 100644 --- a/test/output/yearlyRequests.svg +++ b/test/output/yearlyRequests.svg @@ -41,43 +41,25 @@ - - - - - - - - - 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 index 8eb4ba4f24..0e0d182b1e 100644 --- a/test/output/yearlyRequestsDate.svg +++ b/test/output/yearlyRequestsDate.svg @@ -41,23 +41,14 @@ - - - - - - - - - 2002 From 53487653c44ade7da53609958d5d5f661ea600e9 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 16:30:54 -0700 Subject: [PATCH 13/23] warn on misaligned intervals --- src/marks/axis.js | 33 +++--- src/time.js | 8 +- src/warnings.js | 4 + ...rregular.svg => timeAxisOrdinalSparse.svg} | 22 ++-- test/output/timeAxisOrdinalTicks.svg | 4 +- .../warnTimeAxisOrdinalIncompatible.svg | 110 ++++++++++++++++++ test/plots/time-axis.ts | 12 +- 7 files changed, 153 insertions(+), 40 deletions(-) rename test/output/{timeAxisOrdinalIrregular.svg => timeAxisOrdinalSparse.svg} (92%) create mode 100644 test/output/warnTimeAxisOrdinalIncompatible.svg diff --git a/src/marks/axis.js b/src/marks/axis.js index 627cd42d14..38ffe5e4fa 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -9,6 +9,7 @@ import {isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; import {formatTimeTicks, inferTimeFormat, isTimeYear, isUtcYear} 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"; @@ -555,7 +556,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { } else { data = scale.ticks(ticks); } - // Remove any ticks that aren’t compatible with the scale interval. + // Remove any ticks that aren’t aligned with the scale interval. if (scale.interval) data = data.filter((d) => scale.interval.floor(d) >= d); } else { data = scale.domain(); @@ -563,17 +564,17 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { let compatible = true; if (isInterval(ticks)) { // For ordinal scales with an interval, use the specified tick - // interval, if any. Note that the tick interval and scale interval - // may be incompatible; for example, if the scale interval is "4 - // weeks" and the tick interval is "year", years are not aligned - // with 4-week intervals. So, rather than generate ticks using the - // tick interval, we filter the existing ticks to include only the - // first tick in each interval. - data = data.filter((d, i) => { - const e = ticks.floor(d); - if (scale.interval.floor(e) < e) compatible = false; - return i === 0 || e > ticks.floor(data[i - 1]); - }); + // interval, if any, to filter the domain. If most of the ticks are + // removed, then the tick interval may either be misaligned with the + // scale interval (e.g., "year" and "4 weeks"), or the tick interval + // may be too far apart to be suitable for the multi-line format + // (e.g., "52 weeks" and "4 weeks"). + if (data.length) { + const newdata = data.filter((d) => ticks.floor(d) >= d); + if (newdata.length < data.length / 4) compatible = false; + if (!newdata.length) warn(`Warning: the ${k}-axis ticks interval appears to not align with the scale interval, resulting in no ticks. Try a different interval?`); // prettier-ignore + data = newdata; + } } else { // Compute the positive number n such that taking every nth value // from the scale’s domain produces as close as possible to the @@ -582,11 +583,9 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const n = Math.round(data.length / ticks); if (n > 0) data = data.filter((d, i) => i % n === 0); } - // If the tick and scale time intervals are incompatible, we can’t use - // the multi-line time format. For example, the "day" format (e.g., - // Jan 26) for the "4 weeks" interval does not show the year; hence we - // use the default format (2014-01-26) instead. TODO We need a better - // way to infer whether the ordinal scale is UTC or local time. + // If possible, use the multi-line time format (e.g., Jan 26); + // otherwise use the default ISO format (2014-01-26). TODO We need a + // better way to infer whether the ordinal scale is UTC or local time. if ("text" in options && tickFormat === undefined && compatible && isTimeInterval(scale.interval)) { tickFormat = inferTimeFormat(data, anchor); } diff --git a/src/time.js b/src/time.js index 8f32197440..bf6eb671d5 100644 --- a/src/time.js +++ b/src/time.js @@ -185,15 +185,11 @@ export function inferTimeFormat(dates, anchor) { return formatDefault; } -// TODO This assumes that the format is called sequentially, but if we filter -// the format by wrapping it then it does not behave as desired. We probably -// want to make the format stateful, or we need to pass in a skip value here… function formatConditional(format1, format2, template) { - // TODO n is messy, don’t do this - return (x, i, X, n = 1) => { + return (x, i, X) => { const f1 = format1(x, i); // always shown const f2 = format2(x, i); // only shown if different - const j = i - orderof(X) * n; // detect reversed domains + const j = i - orderof(X); // detect reversed domains return i !== j && X[j] !== undefined && f2 === format2(X[j], j) ? f1 : template(f1, f2); }; } 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/timeAxisOrdinalIrregular.svg b/test/output/timeAxisOrdinalSparse.svg similarity index 92% rename from test/output/timeAxisOrdinalIrregular.svg rename to test/output/timeAxisOrdinalSparse.svg index 15f420e0c0..a68625dfdc 100644 --- a/test/output/timeAxisOrdinalIrregular.svg +++ b/test/output/timeAxisOrdinalSparse.svg @@ -39,20 +39,18 @@ ↑ Close - - - - - - + + + + + - 2013-04-21 - 2014-01-26 - 2015-01-25 - 2016-01-24 - 2017-01-22 - 2018-01-21 + 2013-11-03 + 2014-11-02 + 2015-11-01 + 2016-10-30 + 2017-10-29 2013-05-13 diff --git a/test/output/timeAxisOrdinalTicks.svg b/test/output/timeAxisOrdinalTicks.svg index 51363105ac..e0628b2332 100644 --- a/test/output/timeAxisOrdinalTicks.svg +++ b/test/output/timeAxisOrdinalTicks.svg @@ -41,7 +41,6 @@ ↑ Close - @@ -64,8 +63,7 @@ - May2013 - Jul + Jul2013 Oct Jan2014 Apr diff --git a/test/output/warnTimeAxisOrdinalIncompatible.svg b/test/output/warnTimeAxisOrdinalIncompatible.svg new file mode 100644 index 0000000000..d706820281 --- /dev/null +++ b/test/output/warnTimeAxisOrdinalIncompatible.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + ↑ Close + + + 2013-05-13 + 2013-05-20 + 2013-06-17 + 2013-07-15 + 2013-08-12 + 2013-09-09 + 2013-10-07 + 2013-11-04 + 2013-12-02 + 2013-12-30 + 2014-01-27 + 2014-02-24 + 2014-03-24 + 2014-04-21 + 2014-05-19 + 2014-06-16 + 2014-07-14 + 2014-08-11 + 2014-09-08 + 2014-10-06 + 2014-11-03 + 2014-12-01 + 2014-12-29 + 2015-01-26 + 2015-02-23 + 2015-03-23 + 2015-04-20 + 2015-05-18 + 2015-06-15 + 2015-07-13 + 2015-08-10 + 2015-09-08 + 2015-10-05 + 2015-11-02 + 2015-11-30 + 2015-12-28 + 2016-01-25 + 2016-02-22 + 2016-03-21 + 2016-04-18 + 2016-05-16 + 2016-06-13 + 2016-07-11 + 2016-08-08 + 2016-09-06 + 2016-10-03 + 2016-10-31 + 2016-11-28 + 2016-12-27 + 2017-01-23 + 2017-02-21 + 2017-03-20 + 2017-04-17 + 2017-05-15 + 2017-06-12 + 2017-07-10 + 2017-08-07 + 2017-09-05 + 2017-10-02 + 2017-10-30 + 2017-11-27 + 2017-12-26 + 2018-01-22 + 2018-02-20 + 2018-03-19 + 2018-04-16 + + ⚠️1 warning. Please check the console. + \ No newline at end of file diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts index 6ef2268661..9005fb3277 100644 --- a/test/plots/time-axis.ts +++ b/test/plots/time-axis.ts @@ -91,10 +91,18 @@ export async function timeAxisOrdinal() { }); } -export async function timeAxisOrdinalIrregular() { +export async function warnTimeAxisOrdinalIncompatible() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); return Plot.plot({ - x: {interval: "4 weeks", ticks: "year"}, + 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 timeAxisOrdinalSparse() { + 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"}))] }); } From f2cccf8499cd817e46d351e27d488900dc9bcb93 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 17:14:20 -0700 Subject: [PATCH 14/23] dense grid for sparseCell --- test/output/sparseCell.svg | 176 ------------------------------------- test/plots/sparse-cell.ts | 2 +- 2 files changed, 1 insertion(+), 177 deletions(-) delete mode 100644 test/output/sparseCell.svg diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg deleted file mode 100644 index a23df01596..0000000000 --- a/test/output/sparseCell.svg +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - 3 - 5 - 7 - 9 - 11 - 13 - 15 - 17 - 19 - 21 - 23 - 25 - 27 - - - ← Season - - - - - - - - - - - - - - - - - - - - - - - 1 - 4 - 7 - 10 - 13 - 16 - 19 - 22 - - - Episode → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 8.2Simpsons Roasting on an Open Fire - 7.8Bart the Genius - 7.5Homer's Odyssey - 7.8There's No Disgrace Like Home - 8.1Bart the General - 7.6Moaning Lisa - 7.9The Call of the Simpsons - 7.7The Telltale Head - 7.5Life on the Fast Lane - 7.4Homer's Night Out - 7.8The Crepes of Wrath - 8.3Krusty Gets Busted - 7.9Some Enchanted Evening - 8.2Bart Gets an "F" - 8.3Simpson and Delilah - 8.2Treehouse of Horror - 8.1Two Cars in Every Garage and Three Eyes on Every Fish - 7.5Dancin' Homer - 8.0Dead Putting Society - 7.7Bart vs. Thanksgiving - 8.4Bart the Daredevil - 8.1Itchy & Scratchy & Marge - 7.8Bart Gets Hit by a Car - 8.8One Fish, Two Fish, Blowfish, Blue Fish - 8.2The Way We Was - 8.0Homer vs. Lisa and the 8th Commandment - 6.5The Burns Cage - 6.4How Lisa Got Her Marge Back - 7.1Fland Canyon - 6.7To Courier with Love - 6.4Simprovised - 6.9Orange Is the New Yellow - 6.6Monty Burns' Fleeing Circus - -Friends and Family"[203] - -The Town"[205] - -Treehouse of Horror XXVII"[207] - - \ No newline at end of file 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", From b51c82c19295947961c96a53a8dcac12f19e64be Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 17:50:37 -0700 Subject: [PATCH 15/23] add missing test snapshot --- test/output/sparseCell.svg | 204 +++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 test/output/sparseCell.svg diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg new file mode 100644 index 0000000000..7dbf7cf39f --- /dev/null +++ b/test/output/sparseCell.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 3 + 5 + 7 + 9 + 11 + 13 + 15 + 17 + 19 + 21 + 23 + 25 + 27 + + + ← Season + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 4 + 7 + 10 + 13 + 16 + 19 + 22 + + + Episode → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8.2Simpsons Roasting on an Open Fire + 7.8Bart the Genius + 7.5Homer's Odyssey + 7.8There's No Disgrace Like Home + 8.1Bart the General + 7.6Moaning Lisa + 7.9The Call of the Simpsons + 7.7The Telltale Head + 7.5Life on the Fast Lane + 7.4Homer's Night Out + 7.8The Crepes of Wrath + 8.3Krusty Gets Busted + 7.9Some Enchanted Evening + 8.2Bart Gets an "F" + 8.3Simpson and Delilah + 8.2Treehouse of Horror + 8.1Two Cars in Every Garage and Three Eyes on Every Fish + 7.5Dancin' Homer + 8.0Dead Putting Society + 7.7Bart vs. Thanksgiving + 8.4Bart the Daredevil + 8.1Itchy & Scratchy & Marge + 7.8Bart Gets Hit by a Car + 8.8One Fish, Two Fish, Blowfish, Blue Fish + 8.2The Way We Was + 8.0Homer vs. Lisa and the 8th Commandment + 6.5The Burns Cage + 6.4How Lisa Got Her Marge Back + 7.1Fland Canyon + 6.7To Courier with Love + 6.4Simprovised + 6.9Orange Is the New Yellow + 6.6Monty Burns' Fleeing Circus + -Friends and Family"[203] + -The Town"[205] + -Treehouse of Horror XXVII"[207] + + \ No newline at end of file From 0b4bc968ffa501fb91b9f5e97ba6ac34cb59eb8c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Aug 2023 18:19:39 -0700 Subject: [PATCH 16/23] more robust inferTimeFormat --- src/marks/axis.js | 22 +++---- src/time.js | 18 ++--- test/output/timeAxisOrdinalSparseInterval.svg | 65 +++++++++++++++++++ ...rse.svg => timeAxisOrdinalSparseTicks.svg} | 0 test/plots/time-axis.ts | 10 ++- 5 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 test/output/timeAxisOrdinalSparseInterval.svg rename test/output/{timeAxisOrdinalSparse.svg => timeAxisOrdinalSparseTicks.svg} (100%) diff --git a/src/marks/axis.js b/src/marks/axis.js index 38ffe5e4fa..b1b54d0e13 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -525,6 +525,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const initializeFacets = data == null && (k === "fx" || k === "fy"); const {[k]: scale} = scales; if (!scale) throw new Error(`missing scale: ${k}`); + const domain = scale.domain(); // 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 @@ -551,7 +552,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // 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. - const [min, max] = extent(scale.domain()); + const [min, max] = extent(domain); data = ticks.range(min, ticks.offset(ticks.floor(max))); // inclusive max } else { data = scale.ticks(ticks); @@ -559,21 +560,16 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // Remove any ticks that aren’t aligned with the scale interval. if (scale.interval) data = data.filter((d) => scale.interval.floor(d) >= d); } else { - data = scale.domain(); + data = domain; if (scale.interval) { - let compatible = true; if (isInterval(ticks)) { // For ordinal scales with an interval, use the specified tick - // interval, if any, to filter the domain. If most of the ticks are - // removed, then the tick interval may either be misaligned with the - // scale interval (e.g., "year" and "4 weeks"), or the tick interval - // may be too far apart to be suitable for the multi-line format - // (e.g., "52 weeks" and "4 weeks"). + // interval, if any, to filter the domain. If all of the ticks are + // removed, then the tick interval may be misaligned with the scale + // interval (e.g., "year" and "4 weeks"). if (data.length) { - const newdata = data.filter((d) => ticks.floor(d) >= d); - if (newdata.length < data.length / 4) compatible = false; - if (!newdata.length) warn(`Warning: the ${k}-axis ticks interval appears to not align with the scale interval, resulting in no ticks. Try a different interval?`); // prettier-ignore - data = newdata; + data = data.filter((d) => ticks.floor(d) >= d); + if (!data.length) warn(`Warning: the ${k}-axis ticks interval appears to not align with the scale interval, resulting in no ticks. Try a different interval?`); // prettier-ignore } } else { // Compute the positive number n such that taking every nth value @@ -586,7 +582,7 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { // If possible, use the multi-line time format (e.g., Jan 26); // otherwise use the default ISO format (2014-01-26). TODO We need a // better way to infer whether the ordinal scale is UTC or local time. - if ("text" in options && tickFormat === undefined && compatible && isTimeInterval(scale.interval)) { + if ("text" in options && tickFormat === undefined && isTimeInterval(scale.interval)) { tickFormat = inferTimeFormat(data, anchor); } } diff --git a/src/time.js b/src/time.js index bf6eb671d5..fb8394c7a5 100644 --- a/src/time.js +++ b/src/time.js @@ -1,9 +1,8 @@ -import {bisector, extent, median, pairs, timeFormat, utcFormat} from "d3"; +import {bisector, extent, max, median, 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"; import {timeMonday, timeTuesday, timeWednesday, timeThursday, timeFriday, timeSaturday, timeSunday} from "d3"; -import {formatDefault} from "./format.js"; import {orderof} from "./options.js"; const durationSecond = 1000; @@ -72,13 +71,13 @@ const descendingIntervals = [ ["year", timeYear, "time"], ["month", utcMonth, "utc"], ["month", timeMonth, "time"], - ["day", unixDay, "utc"], - ["day", timeDay, "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. - ["hour", utcHour, "utc"], - ["minute", utcMinute, "utc"], - ["second", utcSecond, "utc"] + ["hour", utcHour, "utc", 3 * durationDay], + ["minute", utcMinute, "utc", 6 * durationHour], + ["second", utcSecond, "utc", 30 * durationMinute] ]; function parseInterval(input, intervals) { @@ -177,12 +176,13 @@ function getTimeTemplate(anchor) { // interval. If no standard interval is compatible (other than milliseconds, // which is universally compatible), returns undefined. export function inferTimeFormat(dates, anchor) { - for (const [name, interval, type] of descendingIntervals) { + const step = max(pairs(dates, (a, b) => Math.abs(b - a))); + for (const [name, interval, type, maxStep] of descendingIntervals) { if (dates.every((d) => interval.floor(d) >= d)) { + if (step > maxStep) break; // e.g., 52 weeks return formatTimeInterval(name, type, anchor); } } - return formatDefault; } function formatConditional(format1, format2, template) { diff --git a/test/output/timeAxisOrdinalSparseInterval.svg b/test/output/timeAxisOrdinalSparseInterval.svg new file mode 100644 index 0000000000..70fcaad5e5 --- /dev/null +++ b/test/output/timeAxisOrdinalSparseInterval.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + ↑ Close + + + + + + + + + + + 2012-11-04 + 2013-11-03 + 2014-11-02 + 2015-11-01 + 2016-10-30 + 2017-10-29 + + + 2013-05-13 + 2013-11-04 + 2014-11-03 + 2015-11-02 + 2016-10-31 + 2017-10-30 + + \ No newline at end of file diff --git a/test/output/timeAxisOrdinalSparse.svg b/test/output/timeAxisOrdinalSparseTicks.svg similarity index 100% rename from test/output/timeAxisOrdinalSparse.svg rename to test/output/timeAxisOrdinalSparseTicks.svg diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts index 9005fb3277..894a6a1e81 100644 --- a/test/plots/time-axis.ts +++ b/test/plots/time-axis.ts @@ -99,7 +99,7 @@ export async function warnTimeAxisOrdinalIncompatible() { }); } -export async function timeAxisOrdinalSparse() { +export async function timeAxisOrdinalSparseTicks() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); return Plot.plot({ x: {interval: "4 weeks", ticks: "52 weeks"}, @@ -107,6 +107,14 @@ export async function timeAxisOrdinalSparse() { }); } +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({ From f80b166f3b5c488e9446dd580bc70082044f5ab6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 18 Aug 2023 10:33:31 -0700 Subject: [PATCH 17/23] detect and generalize standard time intervals --- src/marks/axis.js | 23 +++++--- src/time.js | 95 +++++++++++++++++++++++++++++--- test/output/downloadsOrdinal.svg | 24 ++++---- test/output/ibmTrading.svg | 40 +++++++++----- test/output/timeAxisOrdinal.svg | 24 ++++---- 5 files changed, 152 insertions(+), 54 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index b1b54d0e13..e33a0d4c9f 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -7,7 +7,7 @@ import {isIterable, isNoneish, isTemporal, isInterval, isTimeInterval, orderof} import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; import {isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; -import {formatTimeTicks, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js"; +import {formatTimeTicks, generalizeTimeInterval, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js"; import {initializer} from "../transforms/basic.js"; import {warn} from "../warnings.js"; import {ruleX, ruleY} from "./rule.js"; @@ -572,12 +572,21 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { if (!data.length) warn(`Warning: the ${k}-axis ticks interval appears to not align with the scale interval, resulting in no ticks. Try a different interval?`); // prettier-ignore } } else { - // 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 > 0) data = data.filter((d, i) => i % n === 0); + // If there are too many ticks, we need to prune some. If the scale + // interval is a standard time interval such as "day", we may be + // able to generalize it to a larger aligned time interval. + const interval = generalizeTimeInterval(scale.interval, data.length / ticks); + if (interval) { + const [start, stop] = extent(domain); + data = interval.range(start, +stop + 1); // inclusive stop + } else { + // Otherwise, 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); + } } // If possible, use the multi-line time format (e.g., Jan 26); // otherwise use the default ISO format (2014-01-26). TODO We need a diff --git a/src/time.js b/src/time.js index fb8394c7a5..d0069dd453 100644 --- a/src/time.js +++ b/src/time.js @@ -23,17 +23,57 @@ const formats = [ ["hour", durationHour], ["hour", 12 * durationHour], ["day", durationDay], - ["day", 2 * durationWeek], // new! + ["day", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46 ["month", durationMonth], - ["month", 6 * durationMonth], // new! https://github.com/d3/d3-time/issues/46 + ["month", 6 * durationMonth], // https://github.com/d3/d3-time/issues/46 ["year", durationYear] ]; +// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33 +const tickIntervals = [ + ["second", durationSecond], + ["5 seconds", 5 * durationSecond], + ["15 seconds", 15 * durationSecond], + ["30 seconds", 30 * durationSecond], + ["minute", durationMinute], + ["5 minutes", 5 * durationMinute], + ["15 minutes", 15 * durationMinute], + ["30 minutes", 30 * durationMinute], + ["hour", durationHour], + ["3 hours", 3 * durationHour], + ["6 hours", 6 * durationHour], + ["12 hours", 12 * durationHour], + ["day", durationDay], + ["2 days", 2 * durationDay], + ["week", durationWeek], + ["2 weeks", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46 + ["month", durationMonth], + ["3 months", 3 * durationMonth], + ["6 months", 6 * durationMonth] // https://github.com/d3/d3-time/issues/46 +]; + +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? + ["day", timeDay], // https://github.com/d3/d3-time/issues/62 ["monday", timeMonday], ["tuesday", timeTuesday], ["wednesday", timeWednesday], @@ -63,6 +103,26 @@ const utcIntervals = new Map([ ["year", utcYear] ]); +// 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. +const intervalDuration = Symbol("intervalDuration"); +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 order from largest to // smallest, used to determine the most specific standard time format for a // given array of dates. @@ -80,7 +140,7 @@ const descendingIntervals = [ ["second", utcSecond, "utc", 30 * durationMinute] ]; -function parseInterval(input, intervals) { +function parseInterval(input, intervals, type) { let name = `${input}`.toLowerCase(); if (name.endsWith("s")) name = name.slice(0, -1); // drop plural let period = 1; @@ -101,17 +161,21 @@ 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); + return parseInterval(interval, utcIntervals, "utc"); } export function isUtcYear(i) { @@ -142,6 +206,19 @@ export function formatTimeTicks(scale, data, ticks, anchor) { return formatTimeInterval(name, scale.type, anchor); } +// 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. TODO +// We could handle very small (year) intervals better +// and in a way that is consistent with temporal axes. +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); +} + function formatTimeInterval(name, type, anchor) { const format = type === "time" ? timeFormat : utcFormat; const template = getTimeTemplate(anchor); diff --git a/test/output/downloadsOrdinal.svg b/test/output/downloadsOrdinal.svg index 49960cc270..ca2ae40de0 100644 --- a/test/output/downloadsOrdinal.svg +++ b/test/output/downloadsOrdinal.svg @@ -45,24 +45,20 @@ ↑ downloads - - + - - - - - + + + + - 1Jan - 7 + 6Jan 13 - 19 - 25 - 31 - 6Feb - 12 + 20 + 27 + 3Feb + 10 diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg index d986a2fd86..8275902e65 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -56,22 +56,34 @@ ↑ Volume (USD, millions) - - - - - - - + + + + + + + + + + + + + - 16Apr - 20 - 24 - 28 - 2May - 6 - 10 + 17Apr + 19 + 21 + 23 + 25 + 27 + 29 + 1May + 3 + 5 + 7 + 9 + 11 diff --git a/test/output/timeAxisOrdinal.svg b/test/output/timeAxisOrdinal.svg index b549b9d532..131cbf4b34 100644 --- a/test/output/timeAxisOrdinal.svg +++ b/test/output/timeAxisOrdinal.svg @@ -41,23 +41,27 @@ ↑ Close - + - - + + + - - + + + - May2013 + Jul2013 Jan2014 - Sep - May2015 + Jul + Jan2015 + Jul Jan2016 - Sep - May2017 + Jul + Jan2017 + Jul Jan2018 From 1108e959aeb7b80b6cc4380f385d7d2fe0cf3cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Aug 2023 22:03:32 +0200 Subject: [PATCH 18/23] test: temporal interval on the facet scale --- test/output/walmartsAdditions.svg | 112 ++++++++++++++++++++++++++++++ test/plots/walmarts-decades.ts | 28 ++++++++ 2 files changed, 140 insertions(+) create mode 100644 test/output/walmartsAdditions.svg diff --git a/test/output/walmartsAdditions.svg b/test/output/walmartsAdditions.svg new file mode 100644 index 0000000000..c501455553 --- /dev/null +++ b/test/output/walmartsAdditions.svg @@ -0,0 +1,112 @@ + + + + + 2005— + + + 2000— + + + 1995— + + + 1990— + + + 1985— + + + 1980— + + + 1975— + + + 1970— + + + 1965— + + + 1960— + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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" + }) + ] + }); +} From 4c4214e6d320e21b266ee419d6cede8b8e218fbc Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Aug 2023 11:46:13 -0700 Subject: [PATCH 19/23] improve temporal scales, too --- src/marks/axis.js | 121 +- src/time.js | 72 +- test/output/autoBarTimeSeries.svg | 15 +- test/output/fruitSalesDate.svg | 7 +- test/output/timeAxisExplicitInterval.svg | 1334 ++++++++++++++++- .../timeAxisExplicitNonstandardInterval.svg | 1328 ++++++++++++++++ ...meAxisExplicitNonstandardIntervalTicks.svg | 1329 ++++++++++++++++ ...meAxisOrdinalExplicitIncompatibleTicks.svg | 110 ++ test/output/yearlyRequestsLine.svg | 18 +- test/plots/time-axis.ts | 28 +- 10 files changed, 4183 insertions(+), 179 deletions(-) create mode 100644 test/output/timeAxisExplicitNonstandardInterval.svg create mode 100644 test/output/timeAxisExplicitNonstandardIntervalTicks.svg create mode 100644 test/output/warnTimeAxisOrdinalExplicitIncompatibleTicks.svg diff --git a/src/marks/axis.js b/src/marks/axis.js index e33a0d4c9f..45fbb9482e 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,13 +1,13 @@ -import {extent, format, timeFormat, utcFormat} from "d3"; +import {InternSet, extent, format, 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, isInterval, isTimeInterval, 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, generalizeTimeInterval, inferTimeFormat, isTimeYear, isUtcYear} from "../time.js"; +import {generalizeTimeInterval, inferTimeFormat, intervalDuration, isTimeYear, isUtcYear} from "../time.js"; import {initializer} from "../transforms/basic.js"; import {warn} from "../warnings.js"; import {ruleX, ruleY} from "./rule.js"; @@ -536,65 +536,62 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { tickFormat, tickSpacing = k === "x" ? 80 : 35 } = options; - // For a time scale, or any scale with a time interval, 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" && hasTimeTicks(scale)) ticks = maybeRangeInterval(ticks, scale.type); + // 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)) ticks = maybeRangeInterval(ticks, scale.type); // Lastly use the tickSpacing option to infer the desired tick count. if (ticks == undefined) ticks = inferTickCount(scale, tickSpacing); if (data == null) { if (isIterable(ticks)) { // Use explicit ticks, if specified. data = arrayify(ticks); - } else if (scale.ticks) { - if (isInterval(ticks)) { - // For continuous scales, use the specified tick interval, if any. 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. + } 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); - data = ticks.range(min, ticks.offset(ticks.floor(max))); // inclusive max + 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 { - 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); } - // Remove any ticks that aren’t aligned with the scale interval. - if (scale.interval) data = data.filter((d) => scale.interval.floor(d) >= d); + } else if (scale.ticks) { + data = scale.ticks(ticks); } else { + // For ordinal scales, the domain will already be generated using the + // scale’s interval, if any. data = domain; - if (scale.interval) { - if (isInterval(ticks)) { - // For ordinal scales with an interval, use the specified tick - // interval, if any, to filter the domain. If all of the ticks are - // removed, then the tick interval may be misaligned with the scale - // interval (e.g., "year" and "4 weeks"). - if (data.length) { - data = data.filter((d) => ticks.floor(d) >= d); - if (!data.length) warn(`Warning: the ${k}-axis ticks interval appears to not align with the scale interval, resulting in no ticks. Try a different interval?`); // prettier-ignore - } - } else { - // If there are too many ticks, we need to prune some. If the scale - // interval is a standard time interval such as "day", we may be - // able to generalize it to a larger aligned time interval. - const interval = generalizeTimeInterval(scale.interval, data.length / ticks); - if (interval) { - const [start, stop] = extent(domain); - data = interval.range(start, +stop + 1); // inclusive stop - } else { - // Otherwise, 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); - } - } - // If possible, use the multi-line time format (e.g., Jan 26); - // otherwise use the default ISO format (2014-01-26). TODO We need a - // better way to infer whether the ordinal scale is UTC or local time. - if ("text" in options && tickFormat === undefined && isTimeInterval(scale.interval)) { - tickFormat = inferTimeFormat(data, anchor); - } - } + } + if (!scale.ticks && data.length && data !== domain) { + // For ordinal scales, intersect the ticks with the scale domain, if + // any, 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)]; @@ -641,9 +638,15 @@ function inferTextChannel(scale, data, ticks, tickFormat, anchor) { // 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. export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { - return tickFormat === undefined && isTemporalScale(scale) - ? formatTimeTicks(scale, data, ticks, anchor) - : scale.tickFormat + // If possible, use the multi-line time format (e.g., Jan 26); otherwise use + // the default ISO format (2014-01-26). TODO This is messy and we should + // simplify it. TODO We need a better way to infer whether the ordinal scale + // is UTC or local time. + if (tickFormat === undefined && data && isTemporal(data)) { + tickFormat = inferTimeFormat(data, anchor); + if (tickFormat !== undefined) return tickFormat; + } + return scale.tickFormat && !(isTemporalScale(scale) && tickFormat === undefined) ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : tickFormat === undefined ? isUtcYear(scale.interval) @@ -656,6 +659,10 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { : 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); @@ -700,7 +707,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 && hasTimeTicks(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; @@ -737,6 +744,6 @@ function maybeLabelArrow(labelArrow = "auto") { : keyword(labelArrow, "labelArrow", ["auto", "up", "right", "down", "left"]); } -function hasTimeTicks(scale) { - return isTemporalScale(scale) || isTimeInterval(scale.interval); +function hasTemporalDomain(scale) { + return isTemporal(scale.domain()); } diff --git a/src/time.js b/src/time.js index d0069dd453..80510fc707 100644 --- a/src/time.js +++ b/src/time.js @@ -1,4 +1,4 @@ -import {bisector, extent, max, 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"; @@ -13,24 +13,17 @@ const durationWeek = durationDay * 7; 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", 500], - ["second", durationSecond], - ["second", 30 * durationSecond], - ["minute", durationMinute], - ["minute", 30 * durationMinute], - ["hour", durationHour], - ["hour", 12 * durationHour], - ["day", durationDay], - ["day", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46 - ["month", durationMonth], - ["month", 6 * durationMonth], // https://github.com/d3/d3-time/issues/46 - ["year", durationYear] -]; - // See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L14-L33 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], ["5 seconds", 5 * durationSecond], ["15 seconds", 15 * durationSecond], @@ -49,7 +42,14 @@ const tickIntervals = [ ["2 weeks", 2 * durationWeek], // https://github.com/d3/d3-time/issues/46 ["month", durationMonth], ["3 months", 3 * durationMonth], - ["6 months", 6 * durationMonth] // https://github.com/d3/d3-time/issues/46 + ["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([ @@ -109,8 +109,8 @@ const utcIntervals = new Map([ // 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. -const intervalDuration = Symbol("intervalDuration"); -const intervalType = Symbol("intervalType"); +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". @@ -123,10 +123,11 @@ for (const [name, interval] of utcIntervals) { interval[intervalType] = "utc"; } -// An interleaved array of UTC and local time intervals in order from largest to -// smallest, used to determine the most specific standard time format for a -// given array of dates. -const descendingIntervals = [ +// 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"], @@ -190,26 +191,8 @@ 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) { - 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; - } - const [name] = formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))]; - return formatTimeInterval(name, scale.type, anchor); -} - // 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. TODO -// We could handle very small (year) intervals better -// and in a way that is consistent with temporal axes. +// 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]; @@ -254,7 +237,8 @@ function getTimeTemplate(anchor) { // which is universally compatible), returns undefined. export function inferTimeFormat(dates, anchor) { const step = max(pairs(dates, (a, b) => Math.abs(b - a))); - for (const [name, interval, type, maxStep] of descendingIntervals) { + if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor); + for (const [name, interval, type, maxStep] of formatIntervals) { if (dates.every((d) => interval.floor(d) >= d)) { if (step > maxStep) break; // e.g., 52 weeks return formatTimeInterval(name, type, anchor); 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/fruitSalesDate.svg b/test/output/fruitSalesDate.svg index 4425c34153..7d988241d2 100644 --- a/test/output/fruitSalesDate.svg +++ b/test/output/fruitSalesDate.svg @@ -39,11 +39,8 @@ - 2021-03-15 - 2021-03-16 - - - date + 15Mar + 16 diff --git a/test/output/timeAxisExplicitInterval.svg b/test/output/timeAxisExplicitInterval.svg index ec925189ec..64fa3febb9 100644 --- a/test/output/timeAxisExplicitInterval.svg +++ b/test/output/timeAxisExplicitInterval.svg @@ -40,61 +40,1293 @@ ↑ Close - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - Jul2013 - Oct - Jan2014 - Apr - Jul - Oct - Jan2015 - Apr - Jul - Oct - Jan2016 - Apr - Jul - Oct - Jan2017 - Apr - Jul - Oct - Jan2018 - Apr + Jul2013 + Jan2014 + Jul + Jan2015 + Jul + Jan2016 + Jul + Jan2017 + Jul + Jan2018 - - - - - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/timeAxisExplicitNonstandardInterval.svg b/test/output/timeAxisExplicitNonstandardInterval.svg new file mode 100644 index 0000000000..eae1469602 --- /dev/null +++ b/test/output/timeAxisExplicitNonstandardInterval.svg @@ -0,0 +1,1328 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + + + + 2013-04-21 + 2013-12-29 + 2014-09-07 + 2015-05-17 + 2016-01-24 + 2016-10-02 + 2017-06-11 + 2018-02-18 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/timeAxisExplicitNonstandardIntervalTicks.svg b/test/output/timeAxisExplicitNonstandardIntervalTicks.svg new file mode 100644 index 0000000000..00c643c07b --- /dev/null +++ b/test/output/timeAxisExplicitNonstandardIntervalTicks.svg @@ -0,0 +1,1329 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/warnTimeAxisOrdinalExplicitIncompatibleTicks.svg b/test/output/warnTimeAxisOrdinalExplicitIncompatibleTicks.svg new file mode 100644 index 0000000000..d706820281 --- /dev/null +++ b/test/output/warnTimeAxisOrdinalExplicitIncompatibleTicks.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + ↑ Close + + + 2013-05-13 + 2013-05-20 + 2013-06-17 + 2013-07-15 + 2013-08-12 + 2013-09-09 + 2013-10-07 + 2013-11-04 + 2013-12-02 + 2013-12-30 + 2014-01-27 + 2014-02-24 + 2014-03-24 + 2014-04-21 + 2014-05-19 + 2014-06-16 + 2014-07-14 + 2014-08-11 + 2014-09-08 + 2014-10-06 + 2014-11-03 + 2014-12-01 + 2014-12-29 + 2015-01-26 + 2015-02-23 + 2015-03-23 + 2015-04-20 + 2015-05-18 + 2015-06-15 + 2015-07-13 + 2015-08-10 + 2015-09-08 + 2015-10-05 + 2015-11-02 + 2015-11-30 + 2015-12-28 + 2016-01-25 + 2016-02-22 + 2016-03-21 + 2016-04-18 + 2016-05-16 + 2016-06-13 + 2016-07-11 + 2016-08-08 + 2016-09-06 + 2016-10-03 + 2016-10-31 + 2016-11-28 + 2016-12-27 + 2017-01-23 + 2017-02-21 + 2017-03-20 + 2017-04-17 + 2017-05-15 + 2017-06-12 + 2017-07-10 + 2017-08-07 + 2017-09-05 + 2017-10-02 + 2017-10-30 + 2017-11-27 + 2017-12-26 + 2018-01-22 + 2018-02-20 + 2018-03-19 + 2018-04-16 + + ⚠️1 warning. Please check the console. + \ No newline at end of file diff --git a/test/output/yearlyRequestsLine.svg b/test/output/yearlyRequestsLine.svg index 139168d1ac..5770119d14 100644 --- a/test/output/yearlyRequestsLine.svg +++ b/test/output/yearlyRequestsLine.svg @@ -41,25 +41,19 @@ - - + - - + - - + 2002 - 2004 - 2006 + 2005 2008 - 2010 - 2012 + 2011 2014 - 2016 - 2018 + 2017 diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts index 894a6a1e81..803d113b0d 100644 --- a/test/plots/time-axis.ts +++ b/test/plots/time-axis.ts @@ -79,7 +79,24 @@ 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"})] }); } @@ -122,3 +139,12 @@ export async function timeAxisOrdinalTicks() { 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"}))] + }); +} From 9407067ff48c7b2e79d85f5645631de4e4c57c01 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Aug 2023 12:17:33 -0700 Subject: [PATCH 20/23] better edge cases --- src/marks/axis.js | 28 +++++-------- src/time.js | 24 +++-------- test/output/fruitSalesSingleDate.svg | 63 ++++++++++++++++++++++++++++ test/plots/fruit-sales-date.ts | 13 ++++++ 4 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 test/output/fruitSalesSingleDate.svg diff --git a/src/marks/axis.js b/src/marks/axis.js index 45fbb9482e..50349a212a 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,13 +1,12 @@ -import {InternSet, 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, isInterval, orderof} from "../options.js"; import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js"; -import {isTemporalScale} from "../scales.js"; import {offset} from "../style.js"; -import {generalizeTimeInterval, inferTimeFormat, intervalDuration, 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"; @@ -636,24 +635,17 @@ function inferTextChannel(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) { - // If possible, use the multi-line time format (e.g., Jan 26); otherwise use - // the default ISO format (2014-01-26). TODO This is messy and we should - // simplify it. TODO We need a better way to infer whether the ordinal scale - // is UTC or local time. - if (tickFormat === undefined && data && isTemporal(data)) { - tickFormat = inferTimeFormat(data, anchor); - if (tickFormat !== undefined) return tickFormat; - } - return scale.tickFormat && !(isTemporalScale(scale) && tickFormat === undefined) + return tickFormat === undefined && data && isTemporal(data) + ? inferTimeFormat(data, anchor) ?? formatDefault + : scale.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); diff --git a/src/time.js b/src/time.js index 80510fc707..fcfccc8980 100644 --- a/src/time.js +++ b/src/time.js @@ -135,7 +135,8 @@ const formatIntervals = [ ["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. + // 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] @@ -179,18 +180,6 @@ export function maybeUtcInterval(interval) { return parseInterval(interval, utcIntervals, "utc"); } -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 -} - -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) { @@ -236,13 +225,12 @@ function getTimeTemplate(anchor) { // 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))); + 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 (dates.every((d) => interval.floor(d) >= d)) { - if (step > maxStep) break; // e.g., 52 weeks - return formatTimeInterval(name, type, anchor); - } + 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); } } diff --git a/test/output/fruitSalesSingleDate.svg b/test/output/fruitSalesSingleDate.svg new file mode 100644 index 0000000000..a610c90a99 --- /dev/null +++ b/test/output/fruitSalesSingleDate.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + + + ↑ units + + + + + + 15Mar + + + + + + + + apples + oranges + grapes + + \ No newline at end of file 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"})) + ] + }); +} From 87acc31a18be29ac774c81472cb123e6cb94ae94 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Aug 2023 12:48:40 -0700 Subject: [PATCH 21/23] tweak comment --- src/marks/axis.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index 50349a212a..007425e9d9 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -584,10 +584,10 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { data = domain; } if (!scale.ticks && data.length && data !== domain) { - // For ordinal scales, intersect the ticks with the scale domain, if - // any, 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). + // 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 From 701a8b78af2fd860f4e3de15e6afb26b68aed3ea Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Aug 2023 12:50:11 -0700 Subject: [PATCH 22/23] move tickFormat function detection --- src/legends/swatches.js | 2 +- src/marks/axis.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 007425e9d9..e34fc34833 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -628,9 +628,7 @@ function inferTickCount(scale, tickSpacing) { } function inferTextChannel(scale, data, ticks, tickFormat, anchor) { - return { - value: typeof tickFormat === "function" ? tickFormat : inferTickFormat(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 @@ -640,7 +638,9 @@ function inferTextChannel(scale, data, ticks, tickFormat, anchor) { // 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 && data && isTemporal(data) + return typeof tickFormat === "function" + ? tickFormat + : tickFormat === undefined && data && isTemporal(data) ? inferTimeFormat(data, anchor) ?? formatDefault : scale.tickFormat ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) From 75bc1bc55313d1db8ea5b0e101dcfcc9ef824847 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Aug 2023 17:49:33 -0700 Subject: [PATCH 23/23] minimize diff --- src/marks/axis.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/marks/axis.js b/src/marks/axis.js index e34fc34833..6a3d51f847 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -525,22 +525,17 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { const {[k]: scale} = scales; if (!scale) throw new Error(`missing scale: ${k}`); 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!) - let { - interval, - ticks = maybeRangeInterval(interval, scale.type), - 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)) ticks = maybeRangeInterval(ticks, scale.type); // Lastly use the tickSpacing option to infer the desired tick count. - if (ticks == undefined) ticks = inferTickCount(scale, tickSpacing); + if (ticks === undefined) ticks = maybeRangeInterval(interval, scale.type) ?? inferTickCount(scale, tickSpacing); if (data == null) { if (isIterable(ticks)) { // Use explicit ticks, if specified.