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 @@
+
\ 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 @@
+
\ 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)
+ }
+ )
+ ]
+ });
+}