Skip to content

Commit

Permalink
default multi-line time format (observablehq#1718)
Browse files Browse the repository at this point in the history
* remove maybeAutoTickFormat

* multi-line time format

* Update test/plots/covid-ihme-projected-deaths.ts

Co-authored-by: Philippe Rivière <[email protected]>

* link to d3-time

* fix test size

---------

Co-authored-by: Philippe Rivière <[email protected]>
mbostock and Fil authored Jun 25, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 4a84bd3 commit 780c2f9
Showing 34 changed files with 4,185 additions and 241 deletions.
17 changes: 0 additions & 17 deletions src/axes.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,5 @@
import {format, utcFormat} from "d3";
import {formatIsoDate} from "./format.js";
import {constant, isTemporal, string} from "./options.js";
import {isOrdinalScale} from "./scales.js";

export function inferFontVariant(scale) {
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
}

// D3 doesn’t provide a tick format for ordinal scales; we want shorthand when
// an ordinal domain is numbers or dates, and we want null to mean the empty
// string, not the default identity format. TODO Remove this in favor of the
// axis mark’s inferTickFormat.
export function maybeAutoTickFormat(tickFormat, domain) {
return tickFormat === undefined
? isTemporal(domain)
? formatIsoDate
: string
: typeof tickFormat === "function"
? tickFormat
: (typeof tickFormat === "string" ? (isTemporal(domain) ? utcFormat : format) : constant)(tickFormat);
}
7 changes: 4 additions & 3 deletions src/legends/swatches.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {pathRound as path} from "d3";
import {inferFontVariant, maybeAutoTickFormat} from "../axes.js";
import {createContext, create} from "../context.js";
import {inferFontVariant} from "../axes.js";
import {create, createContext} from "../context.js";
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
import {isOrdinalScale, isThresholdScale} from "../scales.js";
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";
import {inferTickFormat} from "../marks/axis.js";

function maybeScale(scale, key) {
if (key == null) return key;
@@ -85,7 +86,7 @@ function legendItems(scale, options = {}, swatch) {
} = options;
const context = createContext(options);
className = maybeClassName(className);
tickFormat = maybeAutoTickFormat(tickFormat, scale.domain);
if (typeof tickFormat !== "function") tickFormat = inferTickFormat(scale.scale, undefined, tickFormat);

const swatches = create("div", context).attr(
"class",
16 changes: 9 additions & 7 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ import {isIterable, isNoneish, isTemporal, orderof} from "../options.js";
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
import {isTemporalScale} from "../scales.js";
import {offset} from "../style.js";
import {isTimeYear, isUtcYear} from "../time.js";
import {formatTimeTicks, isTimeYear, isUtcYear} from "../time.js";
import {initializer} from "../transforms/basic.js";
import {ruleX, ruleY} from "./rule.js";
import {text, textX, textY} from "./text.js";
@@ -368,7 +368,7 @@ function axisTextKy(
},
function (scale, ticks, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat);
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
}
);
}
@@ -415,7 +415,7 @@ function axisTextKx(
},
function (scale, ticks, channels) {
if (fontVariant === undefined) this.fontVariant = inferFontVariant(scale);
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat);
if (text === undefined) channels.text = inferTextChannel(scale, ticks, tickFormat, anchor);
}
);
}
@@ -565,15 +565,17 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
return m;
}

function inferTextChannel(scale, ticks, tickFormat) {
return {value: inferTickFormat(scale, ticks, tickFormat)};
function inferTextChannel(scale, ticks, tickFormat, anchor) {
return {value: inferTickFormat(scale, 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.
export function inferTickFormat(scale, ticks, tickFormat) {
return scale.tickFormat
export function inferTickFormat(scale, ticks, tickFormat, anchor) {
return tickFormat === undefined && isTemporalScale(scale)
? formatTimeTicks(scale, ticks, anchor)
: scale.tickFormat
? scale.tickFormat(isIterable(ticks) ? null : ticks, tickFormat)
: tickFormat === undefined
? isUtcYear(scale.interval)
73 changes: 73 additions & 0 deletions src/time.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import {bisector, extent, 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 {orderof} from "./options.js";

const durationSecond = 1000;
const durationMinute = durationSecond * 60;
const durationHour = durationMinute * 60;
const durationDay = durationHour * 24;
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", 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]
];

const timeIntervals = new Map([
["second", timeSecond],
@@ -82,3 +109,49 @@ export function isTimeYear(i) {
const date = i.floor(new Date(2000, 11, 31));
return timeYear(date) >= date; // coercing equality
}

export function formatTimeTicks(scale, ticks, anchor) {
const format = scale.type === "time" ? timeFormat : utcFormat;
const template =
anchor === "left" || anchor === "right"
? (f1, f2) => `\n${f1}\n${f2}` // extra newline to keep f1 centered
: anchor === "top"
? (f1, f2) => `${f2}\n${f1}`
: (f1, f2) => `${f1}\n${f2}`;
switch (getTimeTicksInterval(scale, ticks)) {
case "millisecond":
return formatConditional(format(".%L"), format(":%M:%S"), template);
case "second":
return formatConditional(format(":%S"), format("%-I:%M"), template);
case "minute":
return formatConditional(format("%-I:%M"), format("%p"), template);
case "hour":
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":
return format("%Y");
}
throw new Error("unable to format time ticks");
}

// See https://github.com/d3/d3-time/blob/9e8dc940f38f78d7588aad68a54a25b1f0c2d97b/src/ticks.js#L43-L50
function getTimeTicksInterval(scale, ticks) {
const [start, stop] = extent(scale.domain());
const count = typeof ticks === "number" ? ticks : 10; // TODO detect ticks as time interval?
const step = Math.abs(stop - start) / count;
return formats[bisector(([, step]) => Math.log(step)).center(formats, Math.log(step))][0];
}

function formatConditional(format1, format2, template) {
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); // detect reversed domains
return i !== j && X[j] !== undefined && f2 === format2(X[j], j) ? f1 : template(f1, f2);
};
}
10 changes: 5 additions & 5 deletions test/output/aaplCandlestick.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 8 additions & 8 deletions test/output/aaplVolumeRect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions test/output/availability.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 12 additions & 12 deletions test/output/bin1m.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 8 additions & 8 deletions test/output/binTimestamps.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 9 additions & 9 deletions test/output/clamp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 780c2f9

Please sign in to comment.