diff --git a/README.md b/README.md index 7fae0a34aa..ae92607793 100644 --- a/README.md +++ b/README.md @@ -1331,6 +1331,7 @@ In addition to the [standard mark options](#marks), the following optional chann * **y** - the vertical position; bound to the *y* scale * **width** - the image width (in pixels) * **height** - the image height (in pixels) +* **imageRendering** - the [image-rendering](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering) attribute If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. @@ -1523,6 +1524,40 @@ If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be der The rect mark supports the [standard mark options](#marks), including insets and rounded corners. The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. +### Pixel + +#### Plot.pixel(*data*, *options*) + + + +```js +Plot.pixel(triplets, {x: "0", y: "1", fill: "2"}) +``` + +Returns a new raster image with samples at coordinates *x* and *y* optionally set to the given *fill* color and *opacity*. The image is created as a canvas and inserted into the chart as an image with a [data-url](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs) href. + +The **x** and **y** options are both required, as a constant or as a channel, and mapped to the quantitative scales *x* and *y*. Each sample is represented by a rectangular fill from position ﹤x − ½, y − ½﹥ to position ﹤x + ½, y + ½﹥, minus any insets, and possibly rounded to the closest pixel on the underlying canvas to avoid anti-aliasing artifacts. + +The pixel mark supports the following options: + +* **fill** - the sample’s color, as a constant or a channel +* **fillOpacity** - the sample’s opacity, as a constant or a channel +* **opacity** - alias for fillOpacity +* **mixBlendMode** - controls both the [mix-blend-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) attribute of the image, and the [globalCompositeOperation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation) for composing overlapping samples +* **imageRendering** - the [image-rendering](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering) attribute, defaults to pixelated +* **pixelRatio** - the canvas pixel ratio, defaults to [devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) +* **round** - whether the scaled coordinates are rounded, defaults to true +* **inset** - inset of the mark in screen pixels, defaults to zero; note: this length is measured independently of the mark’s pixelRatio +* **insetTop** - inset the top edge +* **insetRight** - inset the right edge +* **insetBottom** - inset the bottom edge +* **insetLeft** - inset the left edge + +Only a few common mark options are supported: **dx**, **dy**, **ariaDescription**, **clip**, and **pointerEvents**. All the options related to the **stroke** (**strokeOpacity**, **paintOrder**…) are ignored, as are **shapeRendering** (but, see imageRendering), **href** and **target**. + + + + #### Plot.rect(*data*, *options*) diff --git a/src/context.js b/src/context.js index b48f339db0..f6fa4da643 100644 --- a/src/context.js +++ b/src/context.js @@ -2,8 +2,11 @@ import {creator, select} from "d3"; import {Projection} from "./projection.js"; export function Context(options = {}, dimensions) { - const {document = typeof window !== "undefined" ? window.document : undefined} = options; - return {document, projection: Projection(options, dimensions)}; + const { + document = typeof window !== "undefined" ? window.document : undefined, + devicePixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1 + } = options; + return {document, devicePixelRatio, projection: Projection(options, dimensions)}; } export function create(name, {document}) { diff --git a/src/index.js b/src/index.js index 1f25d2b7d9..1bca02e75c 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {linearRegressionX, linearRegressionY} from "./marks/linearRegression.js"; export {Link, link} from "./marks/link.js"; +export {Pixel, pixel} from "./marks/pixel.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; diff --git a/src/marks/image.js b/src/marks/image.js index 1039e7256c..8f70e40d1e 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -40,7 +40,7 @@ function maybePathChannel(value) { export class Image extends Mark { constructor(data, options = {}) { - let {x, y, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor} = options; + let {x, y, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor, imageRendering} = options; if (width === undefined && height !== undefined) width = height; else if (height === undefined && width !== undefined) height = width; const [vs, cs] = maybePathChannel(src); @@ -64,6 +64,7 @@ export class Image extends Mark { this.preserveAspectRatio = impliedString(preserveAspectRatio, "xMidYMid"); this.crossOrigin = string(crossOrigin); this.frameAnchor = maybeFrameAnchor(frameAnchor); + this.imageRendering = string(imageRendering); } render(index, scales, channels, dimensions, context) { const {x, y} = scales; @@ -104,6 +105,7 @@ export class Image extends Mark { .call(applyAttr, "href", S ? (i) => S[i] : this.src) .call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio) .call(applyAttr, "crossorigin", this.crossOrigin) + .call(applyAttr, "image-rendering", this.imageRendering) .call(applyChannelStyles, this, channels) ) .node(); diff --git a/src/marks/pixel.js b/src/marks/pixel.js new file mode 100644 index 0000000000..16dbbf8e79 --- /dev/null +++ b/src/marks/pixel.js @@ -0,0 +1,103 @@ +import {create} from "../context.js"; +import {maybeTuple, number, string, valueof} from "../options.js"; +import {Mark} from "../plot.js"; +import {coerceNumbers} from "../scales.js"; +import {applyAttr, applyIndirectStyles, applyTransform} from "../style.js"; + +const defaults = { + ariaLabel: "pixel", + stroke: null +}; + +function numberof(data, x) { + return coerceNumbers(valueof(data, x)); +} + +function maybeRound(round) { + if (round === false || round == null) return null; + if (round === true) return Math.round; + if (typeof round !== "function") throw new Error(`invalid round: ${round}`); + return round; +} + +export class Pixel extends Mark { + constructor(data, options = {}) { + const { + x, + y, + inset = 0, + insetTop = inset, + insetRight = inset, + insetBottom = inset, + insetLeft = inset, + pixelRatio, + imageRendering = "pixelated", + round = true + } = options; + if (x == null) throw new Error("missing channel: x"); + if (y == null) throw new Error("missing channel: y"); + let {r = 0.5, rx = r, ry = r} = options; + rx = number(rx); + ry = number(ry); + let X, Y; + super( + data, + { + x1: {value: {transform: (data) => (X = numberof(data, x)).map((x) => x - rx)}, scale: "x"}, + y1: {value: {transform: (data) => (Y = numberof(data, y)).map((y) => y - ry)}, scale: "y"}, + x2: {value: {transform: () => X.map((x) => x + rx)}, scale: "x"}, + y2: {value: {transform: () => Y.map((y) => y + ry)}, scale: "y"} + }, + options, + defaults + ); + this.insetTop = number(insetTop); + this.insetRight = number(insetRight); + this.insetBottom = number(insetBottom); + this.insetLeft = number(insetLeft); + this.pixelRatio = number(pixelRatio); + this.imageRendering = string(imageRendering); + this.round = maybeRound(round); + } + render(index, scales, channels, dimensions, context) { + const {x1: X1, y1: Y1, x2: X2, y2: Y2, fill: F, fillOpacity: FO} = channels; + const {width, height} = dimensions; + const {document, devicePixelRatio} = context; + const {insetTop, insetRight, insetBottom, insetLeft, pixelRatio = devicePixelRatio, round} = this; + const canvas = document.createElement("canvas"); + canvas.width = width * pixelRatio; + canvas.height = height * pixelRatio; + const context2d = canvas.getContext("2d"); + if (!F) context2d.fillStyle = this.fill; + if (!FO) context2d.globalAlpha = this.fillOpacity ?? this.opacity; + if (this.mixBlendMode) context2d.globalCompositeOperation = this.mixBlendMode; + for (const i of index) { + let x1 = pixelRatio * (Math.min(X1[i], X2[i]) + insetLeft), + x2 = pixelRatio * (Math.max(X1[i], X2[i]) - insetRight), + y1 = pixelRatio * (Math.min(Y1[i], Y2[i]) + insetTop), + y2 = pixelRatio * (Math.max(Y1[i], Y2[i]) - insetBottom); + if (round) (x1 = round(x1)), (x2 = round(x2)), (y1 = round(y1)), (y2 = round(y2)); + if (F) context2d.fillStyle = F[i]; + if (FO) context2d.globalAlpha = FO[i]; + context2d.fillRect(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)); + } + return create("svg:g", context) + .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyTransform, this, scales) + .call((g) => + g + .append("image") + .attr("width", width) + .attr("height", height) + .call(applyAttr, "image-rendering", this.imageRendering) + .attr("xlink:href", canvas.toDataURL()) + ) + .node(); + } +} + +export function pixel(data, options = {}) { + let {x, y, ...remainingOptions} = options; + [x, y] = maybeTuple(x, y); + return new Pixel(data, {...remainingOptions, x, y}); +} diff --git a/src/scales/schemes.js b/src/scales/schemes.js index 40e016731d..1ae531cf69 100644 --- a/src/scales/schemes.js +++ b/src/scales/schemes.js @@ -193,8 +193,8 @@ export function maybeBooleanRange(domain, scheme = "greys") { const range = new Set(); const [f, t] = ordinalRange(scheme, 2); for (const value of domain) { - if (value == null) continue; - if (value === true) range.add(t); + if (value == null) range.add(value); // identity for nullish values + else if (value === true) range.add(t); else if (value === false) range.add(f); else return; } diff --git a/src/style.js b/src/style.js index 05246e5b56..3b194c4435 100644 --- a/src/style.js +++ b/src/style.js @@ -70,6 +70,7 @@ export function styles( if (defaultStroke === null) { stroke = null; strokeOpacity = null; + strokeWidth = null; } // Some marks default to fill with no stroke, while others default to stroke diff --git a/test/output/checkerboard.svg b/test/output/checkerboard.svg new file mode 100644 index 0000000000..d6fef3f3c8 --- /dev/null +++ b/test/output/checkerboard.svg @@ -0,0 +1,62 @@ + + + + + 0 + + + 5 + + + 10 + + + 15 + + + 20 + + + 25 + + + 30 + + + + + 0 + + + 10 + + + 20 + + + 30 + + + 40 + + + 50 + + + + + + \ No newline at end of file diff --git a/test/output/pixel.svg b/test/output/pixel.svg new file mode 100644 index 0000000000..db7892dc86 --- /dev/null +++ b/test/output/pixel.svg @@ -0,0 +1,68 @@ + + + + + 0 + + + 20 + + + 40 + + + 60 + + + 80 + + + 100 + + + 120 + + + 140 + + + 160 + + + + + 0 + + + 50 + + + 100 + + + 150 + + + 200 + + + 250 + + + + + + \ No newline at end of file diff --git a/test/plots/checkerboard.js b/test/plots/checkerboard.js new file mode 100644 index 0000000000..97692fac9c --- /dev/null +++ b/test/plots/checkerboard.js @@ -0,0 +1,20 @@ +import * as Plot from "@observablehq/plot"; + +export default async function () { + const k = 10; + const m = Math.ceil((640 - 60) / k); + const n = Math.ceil((400 - 50) / k); + return Plot.plot({ + marks: [ + Plot.pixel( + {length: n * m}, + { + pixelRatio: 1 / k, + x: (d, i) => i % m, + y: (d, i) => Math.floor(i / m), + fill: (d, i) => ((i % m & 1) === (Math.floor(i / m) & 1) ? true : i % m & 1 ? null : undefined) + } + ) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 4436ba723f..dc1ecb6c7d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -39,6 +39,7 @@ export {default as carsHexbin} from "./cars-hexbin.js"; export {default as carsJitter} from "./cars-jitter.js"; export {default as carsMpg} from "./cars-mpg.js"; export {default as carsParcoords} from "./cars-parcoords.js"; +export {default as checkerboard} from "./checkerboard.js"; export {default as clamp} from "./clamp.js"; export {default as collapsedHistogram} from "./collapsed-histogram.js"; export {default as countryCentroids} from "./country-centroids.js"; @@ -177,6 +178,7 @@ export {default as penguinSpeciesGroup} from "./penguin-species-group.js"; export {default as penguinSpeciesIsland} from "./penguin-species-island.js"; export {default as penguinSpeciesIslandRelative} from "./penguin-species-island-relative.js"; export {default as penguinSpeciesIslandSex} from "./penguin-species-island-sex.js"; +export {default as pixel} from "./pixel.js"; export {default as polylinear} from "./polylinear.js"; export {default as populationByLatitude} from "./population-by-latitude.js"; export {default as populationByLongitude} from "./population-by-longitude.js"; diff --git a/test/plots/pixel.js b/test/plots/pixel.js new file mode 100644 index 0000000000..05cb6d7e42 --- /dev/null +++ b/test/plots/pixel.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; + +export default async function () { + const m = (640 - 60) / 2; + const n = (400 - 50) / 2; + return Plot.plot({ + color: { + scheme: "spectral" + }, + marks: [ + Plot.pixel( + {length: n * m}, + { + x: (d, i) => i % m, + y: (d, i) => Math.floor(i / m), + fill: (d, i) => Math.sin((i % m) / 20) * Math.cos(i / m / 20) + } + ) + ] + }); +}