diff --git a/src/axis.js b/src/axis.js index 8c18c2523c..cff9199c41 100644 --- a/src/axis.js +++ b/src/axis.js @@ -1,5 +1,7 @@ -import {axisTop, axisBottom, axisRight, axisLeft, create} from "d3"; -import {boolean, number, string, keyword, maybeKeyword} from "./mark.js"; +import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3"; +import {formatIsoDate} from "./format.js"; +import {boolean, number, string, keyword, maybeKeyword, constant} from "./mark.js"; +import {isTemporal} from "./scales.js"; export class AxisX { constructor({ @@ -46,10 +48,6 @@ export class AxisX { ) { const { axis, - ticks, - tickSize, - tickPadding, - tickFormat, grid, label, labelAnchor, @@ -61,13 +59,7 @@ export class AxisX { const ty = offsetSign * offset + (axis === "top" ? marginTop : height - marginBottom); return create("svg:g") .attr("transform", `translate(0,${ty})`) - .call((axis === "top" ? axisTop : axisBottom)(x) - .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat) - .tickFormat(typeof tickFormat === "function" || !x.tickFormat ? tickFormat : null) - .tickSizeInner(tickSize) - .tickSizeOuter(0) - .tickPadding(tickPadding) - .tickValues(Array.isArray(ticks) ? ticks : null)) + .call(createAxis(axis === "top" ? axisTop : axisBottom, x, this)) .call(maybeTickRotate, tickRotate) .attr("font-size", null) .attr("font-family", null) @@ -134,10 +126,6 @@ export class AxisY { ) { const { axis, - ticks, - tickSize, - tickPadding, - tickFormat, grid, label, labelAnchor, @@ -149,13 +137,7 @@ export class AxisY { const tx = offsetSign * offset + (axis === "right" ? width - marginRight : marginLeft); return create("svg:g") .attr("transform", `translate(${tx},0)`) - .call((axis === "right" ? axisRight : axisLeft)(y) - .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat) - .tickFormat(typeof tickFormat === "function" || !y.tickFormat ? tickFormat : null) - .tickSizeInner(tickSize) - .tickSizeOuter(0) - .tickPadding(tickPadding) - .tickValues(Array.isArray(ticks) ? ticks : null)) + .call(createAxis(axis === "right" ? axisRight : axisLeft, y, this)) .call(maybeTickRotate, tickRotate) .attr("font-size", null) .attr("font-family", null) @@ -213,6 +195,24 @@ function gridFacetY(fx, tx) { .attr("d", fx.domain().map(v => `M${fx(v) + tx},0h${dx}`).join("")); } +function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) { + if (!scale.tickFormat && typeof tickFormat !== "function") { + // 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. + tickFormat = tickFormat === undefined ? (isTemporal(scale.domain()) ? formatIsoDate : string) + : (typeof tickFormat === "string" ? (isTemporal(scale.domain()) ? utcFormat : format) + : constant)(tickFormat); + } + return axis(scale) + .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat) + .tickFormat(typeof tickFormat === "function" ? tickFormat : null) + .tickSizeInner(tickSize) + .tickSizeOuter(0) + .tickPadding(tickPadding) + .tickValues(Array.isArray(ticks) ? ticks : null); +} + function maybeTickRotate(g, rotate) { if (!(rotate = +rotate)) return; const radians = Math.PI / 180; diff --git a/src/format.js b/src/format.js index ab96eade10..41b15ee02e 100644 --- a/src/format.js +++ b/src/format.js @@ -15,3 +15,28 @@ export function formatWeekday(locale = "en-US", weekday = "short") { } }; } + +export function formatIsoDate(date) { + if (isNaN(date)) return "Invalid Date"; + const hours = date.getUTCHours(); + const minutes = date.getUTCMinutes(); + const seconds = date.getUTCSeconds(); + const milliseconds = date.getUTCMilliseconds(); + return `${formatIsoYear(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}${ + hours || minutes || seconds || milliseconds ? `T${pad(hours, 2)}:${pad(minutes, 2)}${ + seconds || milliseconds ? `:${pad(seconds, 2)}${ + milliseconds ? `.${pad(milliseconds, 3)}` : `` + }` : `` + }Z` : `` + }`; +} + +function formatIsoYear(year) { + return year < 0 ? `-${pad(-year, 6)}` + : year > 9999 ? `+${pad(year, 6)}` + : pad(year, 4); +} + +function pad(value, width) { + return (value + "").padStart(width, "0"); +} diff --git a/src/index.js b/src/index.js index 6f53582bd6..1583d4a352 100644 --- a/src/index.js +++ b/src/index.js @@ -18,4 +18,4 @@ export {map, mapX, mapY} from "./transforms/map.js"; export {windowX, windowY} from "./transforms/window.js"; export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js"; export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js"; -export {formatWeekday, formatMonth} from "./format.js"; +export {formatIsoDate, formatWeekday, formatMonth} from "./format.js"; diff --git a/src/mark.js b/src/mark.js index 97536565f7..d45d1de75d 100644 --- a/src/mark.js +++ b/src/mark.js @@ -75,6 +75,7 @@ export const number = x => x == null ? x : +x; export const boolean = x => x == null ? x : !!x; export const first = d => d[0]; export const second = d => d[1]; +export const constant = x => () => x; // A few extra color keywords not known to d3-color. const colors = new Set(["currentColor", "none"]); diff --git a/src/scales.js b/src/scales.js index 108b15b567..63ed0de19f 100644 --- a/src/scales.js +++ b/src/scales.js @@ -100,7 +100,7 @@ function inferScaleType(key, channels, {type, domain, range}) { return "linear"; } -function isOrdinal(values) { +export function isOrdinal(values) { for (const value of values) { if (value == null) continue; const type = typeof value; @@ -108,7 +108,7 @@ function isOrdinal(values) { } } -function isTemporal(values) { +export function isTemporal(values) { for (const value of values) { if (value == null) continue; return value instanceof Date; diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 8d7d00eb79..540022acfc 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -57,8 +57,8 @@ import { } from "d3"; import {registry, radius, opacity, color} from "./index.js"; import {positive, negative} from "../defined.js"; +import {constant} from "../mark.js"; -const constant = x => () => x; const flip = i => t => i(1 - t); // TODO Allow this to be extended. diff --git a/test/output/fruitSalesDate.svg b/test/output/fruitSalesDate.svg new file mode 100644 index 0000000000..6c25f881a1 --- /dev/null +++ b/test/output/fruitSalesDate.svg @@ -0,0 +1,43 @@ + + + + 0 + + + 5 + + + 10 + + + 15 + + + 20 + + + 25 + + + 30 + ↑ units + + + + 2021-03-15 + + + 2021-03-16 + date + + + + + + + + + + + applesorangesgrapesapplesorangesgrapesbananas + \ No newline at end of file diff --git a/test/plots/crimean-war-overlapped.js b/test/plots/crimean-war-overlapped.js index 7cb5b3b726..d93b4de8e8 100644 --- a/test/plots/crimean-war-overlapped.js +++ b/test/plots/crimean-war-overlapped.js @@ -7,7 +7,7 @@ export default async function() { const data = causes.flatMap(cause => crimea.map(({date, [cause]: deaths}) => ({date, cause, deaths}))); return Plot.plot({ x: { - tickFormat: d3.utcFormat("%b"), + tickFormat: "%b", label: null }, marks: [ diff --git a/test/plots/crimean-war-stacked.js b/test/plots/crimean-war-stacked.js index 7df69d1026..7d2234bf1f 100644 --- a/test/plots/crimean-war-stacked.js +++ b/test/plots/crimean-war-stacked.js @@ -7,7 +7,7 @@ export default async function() { const data = causes.flatMap(cause => crimea.map(({date, [cause]: deaths}) => ({date, cause, deaths}))); return Plot.plot({ x: { - tickFormat: d3.utcFormat("%b"), + tickFormat: "%b", label: null }, marks: [ diff --git a/test/plots/fruit-sales-date.js b/test/plots/fruit-sales-date.js new file mode 100644 index 0000000000..c5a2de3b64 --- /dev/null +++ b/test/plots/fruit-sales-date.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const sales = await d3.csv("data/fruit-sales.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.barY(sales, Plot.stackY({x: "date", y: "units", fill: "fruit"})), + Plot.text(sales, Plot.stackY({x: "date", y: "units", text: "fruit" })) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index c4266a7716..047c598acc 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -28,6 +28,7 @@ export {default as empty} from "./empty.js"; export {default as figcaption} from "./figcaption.js"; export {default as figcaptionHtml} from "./figcaption-html.js"; export {default as fruitSales} from "./fruit-sales.js"; +export {default as fruitSalesDate} from "./fruit-sales-date.js"; export {default as gistempAnomaly} from "./gistemp-anomaly.js"; export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js";