Skip to content

pixel mark #1185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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*)

<!-- jsdoc pixel -->

```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**.


<!-- jsdocEnd pixel -->

#### Plot.rect(*data*, *options*)

<!-- jsdoc rect -->
Expand Down
7 changes: 5 additions & 2 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 3 additions & 1 deletion src/marks/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
103 changes: 103 additions & 0 deletions src/marks/pixel.js
Original file line number Diff line number Diff line change
@@ -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});
}
4 changes: 2 additions & 2 deletions src/scales/schemes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions test/output/checkerboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions test/output/pixel.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions test/plots/checkerboard.js
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the i % m & 1 ? null : part?

Copy link
Member Author

@mbostock mbostock Dec 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to test that both null and undefined produce the same output (transparent).

}
)
]
});
}
2 changes: 2 additions & 0 deletions test/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
21 changes: 21 additions & 0 deletions test/plots/pixel.js
Original file line number Diff line number Diff line change
@@ -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)
}
)
]
});
}