diff --git a/src/marks/axis.js b/src/marks/axis.js index 11984ca935..5d5c8190bd 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,11 +1,11 @@ -import {extent, format, timeFormat, utcFormat} from "d3"; +import {extent, format, timeFormat, timeTicks, utcFormat, utcTicks} 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 {initializer} from "../transforms/basic.js"; @@ -512,8 +512,10 @@ function axisMark(mark, k, 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, tickSpacing, interval} = options; - if (isTemporalScale(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined); + let {ticks, tickSpacing = k === "x" ? 80 : 35, interval} = options; + // TODO what if ticks is a time interval implementation? + // TODO allow ticks to be a function? + if (isTemporalish(scale) && typeof ticks === "string") (interval = ticks), (ticks = undefined); if (data == null) { if (isIterable(ticks)) { data = arrayify(ticks); @@ -531,12 +533,30 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) { 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 = (max - min) / tickSpacing; data = scale.ticks(ticks); } } } else { data = scale.domain(); + // ordinal time + // TODO determine whether the interval is utc or local time? + // TODO assert that the interval is one of utcTick’s known intervals? + // TODO add ceil to the RangeIntervalImplementation interface? + if (isTimeInterval(scale.interval)) { + const type = "utc"; + const [start, stop] = extent(data); + if (interval) { + data = maybeRangeInterval(interval, type).range(start, stop); + data = data.map(scale.interval.ceil ?? scale.interval.floor, scale.interval); + } else { + if (ticks === undefined) { + const [min, max] = extent(scale.range()); + ticks = (max - min) / tickSpacing; + } + data = ticks < data.length ? (type === "time" ? timeTicks : utcTicks)(start, stop, ticks) : data; + } + } } if (k === "y" || k === "x") { facets = [range(data)]; @@ -575,7 +595,7 @@ 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) + return tickFormat === undefined && isTemporalish(scale) ? formatTimeTicks(scale, data, ticks, anchor) : scale.tickFormat ? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat) @@ -672,5 +692,5 @@ function maybeLabelArrow(labelArrow = "auto") { } function isTemporalish(scale) { - return isTemporalScale(scale) || scale.interval != null; + return isTemporalScale(scale) || (isOrdinalScale(scale) && isTimeInterval(scale.interval)); } diff --git a/src/options.js b/src/options.js index f70a118ef8..5565f02e9e 100644 --- a/src/options.js +++ b/src/options.js @@ -348,6 +348,14 @@ export function maybeNiceInterval(interval, type) { return interval; } +export function isTimeInterval(t) { + return isInterval(t) && typeof t?.floor === "function" && t.floor() instanceof Date; +} + +export function isInterval(t) { + return typeof t?.range === "function"; +} + // This distinguishes between per-dimension options and a standalone value. export function maybeValue(value) { return value === undefined || isOptions(value) ? value : {value}; diff --git a/src/time.js b/src/time.js index 661a928184..4cf8fe84c2 100644 --- a/src/time.js +++ b/src/time.js @@ -110,7 +110,7 @@ export function isTimeYear(i) { return timeYear(date) >= date; // coercing equality } -export function formatTimeTicks(scale, data, ticks, anchor) { +export function formatTimeTicks(scale, data = scale.domain(), ticks, anchor) { const format = scale.type === "time" ? timeFormat : utcFormat; const template = anchor === "left" || anchor === "right" @@ -145,11 +145,12 @@ export function formatTimeTicks(scale, data, ticks, anchor) { // 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; + 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 formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0]; } 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..57332d56bc 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..b785aa053b 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -56,60 +56,34 @@ ↑ Volume (USD, millions) - - - - - - - - - - - - - - - 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/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..c37db16b13 --- /dev/null +++ b/test/output/timeAxisOrdinalIrregular.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + ↑ Close + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + 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..e0628b2332 --- /dev/null +++ b/test/output/timeAxisOrdinalTicks.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + + + + + + + + + + + + + + + + + + + + + + Jul2013 + Oct + Jan2014 + Apr + Jul + Oct + Jan2015 + Apr + Jul + Oct + Jan2016 + Apr + Jul + Oct + Jan2017 + Apr + Jul + Oct + Jan2018 + Apr + + + 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/plots/downloads-ordinal.ts b/test/plots/downloads-ordinal.ts index 3cadf0715b..79f175a88c 100644 --- a/test/plots/downloads-ordinal.ts +++ b/test/plots/downloads-ordinal.ts @@ -6,13 +6,8 @@ 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..fb07fff2fc 100644 --- a/test/plots/ibm-trading.ts +++ b/test/plots/ibm-trading.ts @@ -5,11 +5,7 @@ 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"}))] + }); +}