diff --git a/package.json b/package.json index efb33b6b36..d202787411 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "scripts": { "test": "yarn test:mocha && yarn test:tsc && yarn test:lint && yarn test:prettier", "test:coverage": "npx c8 yarn test:mocha", - "test:mocha": "mkdir -p test/output && mocha 'test/**/*-test.*' 'test/plot.js'", + "test:mocha": "mkdir -p test/output && TZ=America/Los_Angeles mocha 'test/**/*-test.*' 'test/plot.js'", "test:lint": "eslint src test", "test:prettier": "prettier --check src test", "test:tsc": "tsc", diff --git a/src/marks/axis.js b/src/marks/axis.js index 9e090db33e..1a59ef5501 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -638,7 +638,7 @@ export function inferTickFormat(scale, data, ticks, tickFormat, anchor) { return typeof tickFormat === "function" ? tickFormat : tickFormat === undefined && data && isTemporal(data) - ? inferTimeFormat(data, anchor) ?? formatDefault + ? inferTimeFormat(scale.type, data, anchor) ?? formatDefault : scale.tickFormat ? scale.tickFormat(typeof ticks === "number" ? ticks : null, tickFormat) : tickFormat === undefined diff --git a/src/time.js b/src/time.js index 5197a09f52..58314ec0d6 100644 --- a/src/time.js +++ b/src/time.js @@ -123,23 +123,39 @@ for (const [name, interval] of utcIntervals) { interval[intervalType] = "utc"; } +const utcFormatIntervals = [ + ["year", utcYear, "utc"], + ["month", utcMonth, "utc"], + ["day", unixDay, "utc", 6 * durationMonth], + ["hour", utcHour, "utc", 3 * durationDay], + ["minute", utcMinute, "utc", 6 * durationHour], + ["second", utcSecond, "utc", 30 * durationMinute] +]; + +const timeFormatIntervals = [ + ["year", timeYear, "time"], + ["month", timeMonth, "time"], + ["day", timeDay, "time", 6 * durationMonth], + ["hour", timeHour, "time", 3 * durationDay], + ["minute", timeMinute, "time", 6 * durationHour], + ["second", timeSecond, "time", 30 * durationMinute] +]; + // An interleaved array of UTC and local time intervals, in descending order // from largest to smallest, used to determine the most specific standard time // format for a given array of dates. This is a subset of the tick intervals // listed above; we only need the breakpoints where the format changes. const formatIntervals = [ - ["year", utcYear, "utc"], - ["year", timeYear, "time"], - ["month", utcMonth, "utc"], - ["month", timeMonth, "time"], - ["day", unixDay, "utc", 6 * durationMonth], - ["day", timeDay, "time", 6 * durationMonth], + utcFormatIntervals[0], + timeFormatIntervals[0], + utcFormatIntervals[1], + timeFormatIntervals[1], + utcFormatIntervals[2], + timeFormatIntervals[2], // Below day, local time typically has an hourly offset from UTC and hence the // two are aligned and indistinguishable; therefore, we only consider UTC, and // we don’t consider these if the domain only has a single value. - ["hour", utcHour, "utc", 3 * durationDay], - ["minute", utcMinute, "utc", 6 * durationHour], - ["second", utcSecond, "utc", 30 * durationMinute] + ...utcFormatIntervals.slice(3) ]; function parseInterval(input, intervals, type) { @@ -238,16 +254,20 @@ function getTimeTemplate(anchor) { : (f1, f2) => `${f1}\n${f2}`; } +function getFormatIntervals(type) { + return type === "time" ? timeFormatIntervals : type === "utc" ? utcFormatIntervals : formatIntervals; +} + // Given an array of dates, returns the largest compatible standard time // interval. If no standard interval is compatible (other than milliseconds, // which is universally compatible), returns undefined. -export function inferTimeFormat(dates, anchor) { +export function inferTimeFormat(type, dates, anchor) { const step = max(pairs(dates, (a, b) => Math.abs(b - a))); // maybe undefined! if (step < 1000) return formatTimeInterval("millisecond", "utc", anchor); - for (const [name, interval, type, maxStep] of formatIntervals) { + for (const [name, interval, intervalType, maxStep] of getFormatIntervals(type)) { 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); + if (dates.every((d) => interval.floor(d) >= d)) return formatTimeInterval(name, intervalType, anchor); } } diff --git a/test/output/timeAxisLocal.svg b/test/output/timeAxisLocal.svg new file mode 100644 index 0000000000..94dbd567b1 --- /dev/null +++ b/test/output/timeAxisLocal.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + 8:30AM + 9:00 + 9:30 + 10:00 + 10:30 + 11:00 + 11:30 + 12:00PM + + + + + + + + + \ No newline at end of file diff --git a/test/plots/time-axis.ts b/test/plots/time-axis.ts index 803d113b0d..38e6c2123a 100644 --- a/test/plots/time-axis.ts +++ b/test/plots/time-axis.ts @@ -148,3 +148,14 @@ export async function warnTimeAxisOrdinalExplicitIncompatibleTicks() { marks: [Plot.barY(aapl, Plot.groupX({y: "median", title: "min"}, {title: "Date", x: "Date", y: "Close"}))] }); } + +export async function timeAxisLocal() { + const dates = [ + "2023-09-30T15:05:48.452Z", + "2023-09-30T16:05:48.452Z", + "2023-09-30T17:05:48.452Z", + "2023-09-30T18:05:48.452Z", + "2023-09-30T19:05:48.452Z" + ].map((d) => new Date(d)); + return Plot.dotX(dates).plot({x: {type: "time"}}); +}