diff --git a/README.md b/README.md index f7a823a6de..ff45f65d27 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Plot automatically generates axes for position scales. You can configure these a * *scale*.**tickPadding** - the separation between the tick and its label (in pixels; default 3) * *scale*.**tickFormat** - to format tick values, either a function or format specifier string; see [Formats](#formats) * *scale*.**tickRotate** - whether to rotate tick labels (an angle in degrees clockwise; default 0) -* *scale*.**grid** - if true, draw grid lines across the plot for each tick +* *scale*.**grid** - if true, a positive number, or an array of tick values, draw grid lines across the plot for each tick; if specified as an array, the default domain includes them * *scale*.**line** - if true, draw the axis line * *scale*.**label** - a string to label the axis * *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center* diff --git a/src/axis.js b/src/axis.js index bdef81300d..fa533c2167 100644 --- a/src/axis.js +++ b/src/axis.js @@ -3,7 +3,7 @@ import {create} from "./context.js"; import {formatIsoDate} from "./format.js"; import {radians} from "./math.js"; import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./options.js"; -import {applyAttr, impliedString} from "./style.js"; +import {applyAttr, impliedString, offset} from "./style.js"; export class AxisX { constructor({ @@ -30,7 +30,7 @@ export class AxisX { this.tickPadding = number(tickPadding); this.tickFormat = maybeTickFormat(tickFormat); this.fontVariant = impliedString(fontVariant, "normal"); - this.grid = boolean(grid); + this.grid = maybeGrid(grid); this.label = string(label); this.labelAnchor = maybeKeyword(labelAnchor, "labelAnchor", ["center", "left", "right"]); this.labelOffset = number(labelOffset); @@ -66,7 +66,8 @@ export class AxisX { labelOffset, line, name, - tickRotate + tickRotate, + ticks } = this; const offset = name === "x" ? 0 : axis === "top" ? marginTop - facetMarginTop : marginBottom - facetMarginBottom; const offsetSign = axis === "top" ? -1 : 1; @@ -74,15 +75,19 @@ export class AxisX { return create("svg:g", context) .call(applyAria, this) .attr("transform", `translate(${offsetLeft},${ty})`) + .call(!grid ? () => {} + : createGridX( + grid(x, ticks), + x, + fy ? fy.bandwidth() : offsetSign * (marginBottom + marginTop - height), + fy ? (index ? take(fy.domain(), index) : fy.domain()).map(d => fy(d) - ty) : [0] + )) .call(createAxis(axis === "top" ? axisTop : axisBottom, x, this)) .call(maybeTickRotate, tickRotate) .attr("font-size", null) .attr("font-family", null) .attr("font-variant", fontVariant) .call(!line ? g => g.select(".domain").remove() : () => {}) - .call(!grid ? () => {} - : fy ? gridFacetX(index, fy, -ty) - : gridX(offsetSign * (marginBottom + marginTop - height))) .call(!label ? () => {} : g => g.append("text") .attr("fill", "currentColor") .attr("transform", `translate(${ @@ -124,7 +129,7 @@ export class AxisY { this.tickPadding = number(tickPadding); this.tickFormat = maybeTickFormat(tickFormat); this.fontVariant = impliedString(fontVariant, "normal"); - this.grid = boolean(grid); + this.grid = maybeGrid(grid); this.label = string(label); this.labelAnchor = maybeKeyword(labelAnchor, "labelAnchor", ["center", "top", "bottom"]); this.labelOffset = number(labelOffset); @@ -158,7 +163,8 @@ export class AxisY { labelOffset, line, name, - tickRotate + tickRotate, + ticks } = this; const offset = name === "y" ? 0 : axis === "left" ? marginLeft - facetMarginLeft : marginRight - facetMarginRight; const offsetSign = axis === "left" ? -1 : 1; @@ -166,15 +172,19 @@ export class AxisY { return create("svg:g", context) .call(applyAria, this) .attr("transform", `translate(${tx},${offsetTop})`) + .call(!grid ? () => {} + : createGridY( + grid(y, ticks), + y, + fx ? fx.bandwidth() : offsetSign * (marginLeft + marginRight - width), + fx ? (index ? take(fx.domain(), index) : fx.domain()).map(d => fx(d) - tx) : [0] + )) .call(createAxis(axis === "right" ? axisRight : axisLeft, y, this)) .call(maybeTickRotate, tickRotate) .attr("font-size", null) .attr("font-family", null) .attr("font-variant", fontVariant) .call(!line ? g => g.select(".domain").remove() : () => {}) - .call(!grid ? () => {} - : fx ? gridFacetY(index, fx, -tx) - : gridY(offsetSign * (marginLeft + marginRight - width))) .call(!label ? () => {} : g => g.append("text") .attr("fill", "currentColor") .attr("font-variant", fontVariant == null ? null : "normal") @@ -204,40 +214,6 @@ function applyAria(selection, { applyAttr(selection, "aria-description", ariaDescription); } -function gridX(y2) { - return g => g.selectAll(".tick line") - .clone(true) - .attr("stroke-opacity", 0.1) - .attr("y2", y2); -} - -function gridY(x2) { - return g => g.selectAll(".tick line") - .clone(true) - .attr("stroke-opacity", 0.1) - .attr("x2", x2); -} - -function gridFacetX(index, fy, ty) { - const dy = fy.bandwidth(); - const domain = fy.domain(); - return g => g.selectAll(".tick") - .append("path") - .attr("stroke", "currentColor") - .attr("stroke-opacity", 0.1) - .attr("d", (index ? take(domain, index) : domain).map(v => `M0,${fy(v) + ty}v${dy}`).join("")); -} - -function gridFacetY(index, fx, tx) { - const dx = fx.bandwidth(); - const domain = fx.domain(); - return g => g.selectAll(".tick") - .append("path") - .attr("stroke", "currentColor") - .attr("stroke-opacity", 0.1) - .attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join("")); -} - function maybeTicks(ticks) { return ticks === null ? [] : ticks; } @@ -288,3 +264,41 @@ function maybeTickRotate(g, rotate) { text.setAttribute("dy", "0.32em"); } } + +function createGridX(ticks, x, dy, steps) { + return g => g.append("g") + .attr("class", "grid") + .attr("stroke", "currentColor") + .attr("stroke-opacity", "0.1") + .selectAll() + .data(steps) + .join("g") + .attr("transform", v => `translate(${offset},${v})`) + .selectAll() + .data(ticks) + .join("path") + .attr("d", d => `M${x(d)},0v${dy}`); +} + +function createGridY(ticks, y, dx, steps) { + return g => g.append("g") + .attr("class", "grid") + .attr("stroke", "currentColor") + .attr("stroke-opacity", "0.1") + .selectAll() + .data(steps) + .join("g") + .attr("transform", v => `translate(${v},${offset})`) + .selectAll() + .data(ticks) + .join("path") + .attr("d", d => `M0,${y(d)}h${dx}`); +} + +function maybeGrid(grid) { + if (!grid) return false; + if (grid === true) return (scale, ticks) => (scale.ticks ? scale.ticks(ticks) : scale.domain()); + if (Array.isArray(grid)) return constant(grid.map(d => d == null ? null : +d).filter(d => !isNaN(d))); + if (grid === +grid) return (scale) => (scale.ticks ? scale.ticks.apply(scale, [grid]) : scale.domain()); + throw new Error(`Unexpected grid option: ${grid}`); +} diff --git a/src/scales.js b/src/scales.js index c388997bce..2191b4a683 100644 --- a/src/scales.js +++ b/src/scales.js @@ -19,6 +19,7 @@ export function Scales(channelsByScale, { nice, clamp, zero, + grid, align, padding, ...options @@ -31,6 +32,7 @@ export function Scales(channelsByScale, { nice, clamp, zero, + grid, align, padding, ...scaleOptions diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 9a40774d1b..c8275697c7 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -54,7 +54,8 @@ export function ScaleQ(key, scale, channels, { nice, clamp, zero, - domain = inferAutoDomain(key, channels), + grid, + domain = inferAutoDomain(key, channels, grid), unknown, round, scheme, @@ -207,8 +208,9 @@ export function inferDomain(channels, f = finite) { ] : [0, 1]; } -function inferAutoDomain(key, channels) { +function inferAutoDomain(key, channels, grid) { const type = registry.get(key); + if (["x", "y"].includes(key) && Array.isArray(grid)) channels = [...channels, {value: grid}]; return (type === radius || type === opacity || type === length ? inferZeroDomain : inferDomain)(channels); } diff --git a/test/output/anscombeQuartetGrid.svg b/test/output/anscombeQuartetGrid.svg new file mode 100644 index 0000000000..37be5dbeab --- /dev/null +++ b/test/output/anscombeQuartetGrid.svg @@ -0,0 +1,405 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + ↑ y + + + + 1 + + + 2 + + + 3 + + + 4 + series + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + + 10 + + + 15 + + + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + + 10 + + + 15 + + + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + + 10 + + + 15 + + + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + + 10 + + + 15 + + + 20 + x → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/anscombe-quartet-grid.js b/test/plots/anscombe-quartet-grid.js new file mode 100644 index 0000000000..05b53c706c --- /dev/null +++ b/test/plots/anscombe-quartet-grid.js @@ -0,0 +1,20 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const anscombe = await d3.csv("data/anscombe.csv", d3.autoType); + return Plot.plot({ + nice: true, + inset: 5, + grid: 30, + width: 960, + height: 240, + facet: { + data: anscombe, + x: "series" + }, + marks: [ + Plot.dot(anscombe, {x: "x", y: "y"}) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index b3f4d78380..a13331338b 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -7,6 +7,7 @@ export {default as aaplMonthly} from "./aapl-monthly.js"; export {default as aaplVolume} from "./aapl-volume.js"; export {default as aaplVolumeRect} from "./aapl-volume-rect.js"; export {default as anscombeQuartet} from "./anscombe-quartet.js"; +export {default as anscombeQuartetGrid} from "./anscombe-quartet-grid.js"; export {default as athletesBinsColors} from "./athletes-bins-colors.js"; export {default as athletesBirthdays} from "./athletes-birthdays.js"; export {default as athletesHeightWeight} from "./athletes-height-weight.js";