Skip to content

Commit b09fdbe

Browse files
Filmbostock
andauthored
temporal tick format (#321)
* time tickFormat When a point or band scale is based on time values, use a time format for ticks; the default time format is the multi-time format from utcScale, but can be overridden with tickFormat: "%a". Similarly, if the point or band scale is based on numeric (ie non-ordinal) values, tickFormat: "," is interpreted with d3.format. closes #212 * tweak branching * shorten * export formatIsoDate Co-authored-by: Mike Bostock <[email protected]>
1 parent ecf5483 commit b09fdbe

File tree

11 files changed

+112
-30
lines changed

11 files changed

+112
-30
lines changed

src/axis.js

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import {axisTop, axisBottom, axisRight, axisLeft, create} from "d3";
2-
import {boolean, number, string, keyword, maybeKeyword} from "./mark.js";
1+
import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3";
2+
import {formatIsoDate} from "./format.js";
3+
import {boolean, number, string, keyword, maybeKeyword, constant} from "./mark.js";
4+
import {isTemporal} from "./scales.js";
35

46
export class AxisX {
57
constructor({
@@ -46,10 +48,6 @@ export class AxisX {
4648
) {
4749
const {
4850
axis,
49-
ticks,
50-
tickSize,
51-
tickPadding,
52-
tickFormat,
5351
grid,
5452
label,
5553
labelAnchor,
@@ -61,13 +59,7 @@ export class AxisX {
6159
const ty = offsetSign * offset + (axis === "top" ? marginTop : height - marginBottom);
6260
return create("svg:g")
6361
.attr("transform", `translate(0,${ty})`)
64-
.call((axis === "top" ? axisTop : axisBottom)(x)
65-
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)
66-
.tickFormat(typeof tickFormat === "function" || !x.tickFormat ? tickFormat : null)
67-
.tickSizeInner(tickSize)
68-
.tickSizeOuter(0)
69-
.tickPadding(tickPadding)
70-
.tickValues(Array.isArray(ticks) ? ticks : null))
62+
.call(createAxis(axis === "top" ? axisTop : axisBottom, x, this))
7163
.call(maybeTickRotate, tickRotate)
7264
.attr("font-size", null)
7365
.attr("font-family", null)
@@ -134,10 +126,6 @@ export class AxisY {
134126
) {
135127
const {
136128
axis,
137-
ticks,
138-
tickSize,
139-
tickPadding,
140-
tickFormat,
141129
grid,
142130
label,
143131
labelAnchor,
@@ -149,13 +137,7 @@ export class AxisY {
149137
const tx = offsetSign * offset + (axis === "right" ? width - marginRight : marginLeft);
150138
return create("svg:g")
151139
.attr("transform", `translate(${tx},0)`)
152-
.call((axis === "right" ? axisRight : axisLeft)(y)
153-
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)
154-
.tickFormat(typeof tickFormat === "function" || !y.tickFormat ? tickFormat : null)
155-
.tickSizeInner(tickSize)
156-
.tickSizeOuter(0)
157-
.tickPadding(tickPadding)
158-
.tickValues(Array.isArray(ticks) ? ticks : null))
140+
.call(createAxis(axis === "right" ? axisRight : axisLeft, y, this))
159141
.call(maybeTickRotate, tickRotate)
160142
.attr("font-size", null)
161143
.attr("font-family", null)
@@ -213,6 +195,24 @@ function gridFacetY(fx, tx) {
213195
.attr("d", fx.domain().map(v => `M${fx(v) + tx},0h${dx}`).join(""));
214196
}
215197

198+
function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
199+
if (!scale.tickFormat && typeof tickFormat !== "function") {
200+
// D3 doesn’t provide a tick format for ordinal scales; we want shorthand
201+
// when an ordinal domain is numbers or dates, and we want null to mean the
202+
// empty string, not the default identity format.
203+
tickFormat = tickFormat === undefined ? (isTemporal(scale.domain()) ? formatIsoDate : string)
204+
: (typeof tickFormat === "string" ? (isTemporal(scale.domain()) ? utcFormat : format)
205+
: constant)(tickFormat);
206+
}
207+
return axis(scale)
208+
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)
209+
.tickFormat(typeof tickFormat === "function" ? tickFormat : null)
210+
.tickSizeInner(tickSize)
211+
.tickSizeOuter(0)
212+
.tickPadding(tickPadding)
213+
.tickValues(Array.isArray(ticks) ? ticks : null);
214+
}
215+
216216
function maybeTickRotate(g, rotate) {
217217
if (!(rotate = +rotate)) return;
218218
const radians = Math.PI / 180;

src/format.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,28 @@ export function formatWeekday(locale = "en-US", weekday = "short") {
1515
}
1616
};
1717
}
18+
19+
export function formatIsoDate(date) {
20+
if (isNaN(date)) return "Invalid Date";
21+
const hours = date.getUTCHours();
22+
const minutes = date.getUTCMinutes();
23+
const seconds = date.getUTCSeconds();
24+
const milliseconds = date.getUTCMilliseconds();
25+
return `${formatIsoYear(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}${
26+
hours || minutes || seconds || milliseconds ? `T${pad(hours, 2)}:${pad(minutes, 2)}${
27+
seconds || milliseconds ? `:${pad(seconds, 2)}${
28+
milliseconds ? `.${pad(milliseconds, 3)}` : ``
29+
}` : ``
30+
}Z` : ``
31+
}`;
32+
}
33+
34+
function formatIsoYear(year) {
35+
return year < 0 ? `-${pad(-year, 6)}`
36+
: year > 9999 ? `+${pad(year, 6)}`
37+
: pad(year, 4);
38+
}
39+
40+
function pad(value, width) {
41+
return (value + "").padStart(width, "0");
42+
}

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ export {map, mapX, mapY} from "./transforms/map.js";
1818
export {windowX, windowY} from "./transforms/window.js";
1919
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
2020
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
21-
export {formatWeekday, formatMonth} from "./format.js";
21+
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";

src/mark.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const number = x => x == null ? x : +x;
7575
export const boolean = x => x == null ? x : !!x;
7676
export const first = d => d[0];
7777
export const second = d => d[1];
78+
export const constant = x => () => x;
7879

7980
// A few extra color keywords not known to d3-color.
8081
const colors = new Set(["currentColor", "none"]);

src/scales.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,15 @@ function inferScaleType(key, channels, {type, domain, range}) {
100100
return "linear";
101101
}
102102

103-
function isOrdinal(values) {
103+
export function isOrdinal(values) {
104104
for (const value of values) {
105105
if (value == null) continue;
106106
const type = typeof value;
107107
return type === "string" || type === "boolean";
108108
}
109109
}
110110

111-
function isTemporal(values) {
111+
export function isTemporal(values) {
112112
for (const value of values) {
113113
if (value == null) continue;
114114
return value instanceof Date;

src/scales/quantitative.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ import {
5757
} from "d3";
5858
import {registry, radius, opacity, color} from "./index.js";
5959
import {positive, negative} from "../defined.js";
60+
import {constant} from "../mark.js";
6061

61-
const constant = x => () => x;
6262
const flip = i => t => i(1 - t);
6363

6464
// TODO Allow this to be extended.

test/output/fruitSalesDate.svg

Lines changed: 43 additions & 0 deletions
Loading

test/plots/crimean-war-overlapped.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default async function() {
77
const data = causes.flatMap(cause => crimea.map(({date, [cause]: deaths}) => ({date, cause, deaths})));
88
return Plot.plot({
99
x: {
10-
tickFormat: d3.utcFormat("%b"),
10+
tickFormat: "%b",
1111
label: null
1212
},
1313
marks: [

test/plots/crimean-war-stacked.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default async function() {
77
const data = causes.flatMap(cause => crimea.map(({date, [cause]: deaths}) => ({date, cause, deaths})));
88
return Plot.plot({
99
x: {
10-
tickFormat: d3.utcFormat("%b"),
10+
tickFormat: "%b",
1111
label: null
1212
},
1313
marks: [

test/plots/fruit-sales-date.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
4+
export default async function() {
5+
const sales = await d3.csv("data/fruit-sales.csv", d3.autoType);
6+
return Plot.plot({
7+
marks: [
8+
Plot.barY(sales, Plot.stackY({x: "date", y: "units", fill: "fruit"})),
9+
Plot.text(sales, Plot.stackY({x: "date", y: "units", text: "fruit" }))
10+
]
11+
});
12+
}

0 commit comments

Comments
 (0)