Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/marks/raster.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,12 @@ If **width** is specified, **x1** defaults to 0 and **x2** defaults to **width**

The following raster-specific constant options are supported:

* **colorSpace** - the [color space](https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace) <VersionBadge pr="2143" />
* **interpolate** - the [spatial interpolation method](#spatial-interpolators)
* **imageRendering** - the [image-rendering attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering); defaults to *auto* (bilinear)
* **blur** - a non-negative pixel radius for smoothing; defaults to 0

The **imageRendering** option may be set to *pixelated* for a sharper image. The **interpolate** option is ignored when **fill** or **fillOpacity** is a function of *x* and *y*.
The **colorSpace** may be set to *display-p3* for the Display P3 color space. The **imageRendering** option may be set to *pixelated* for a sharper image. The **interpolate** option is ignored when **fill** or **fillOpacity** is a function of *x* and *y*.

## raster(*data*, *options*) {#raster}

Expand Down
8 changes: 8 additions & 0 deletions src/marks/raster.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ export interface RasterOptions extends Omit<MarkOptions, "fill" | "fillOpacity">
*/
imageRendering?: string;

/**
* The [color space][1] of the backing canvas. Defaults to *srgb*; set to
* *display-p3* for the Display P3 color space.
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace
*/
colorSpace?: "srgb" | "display-p3" | string;

/**
* The fill, typically bound to the *color* scale. Can be specified as a
* constant, a channel based on the sample *data*, or as a function *f*(*x*,
Expand Down
38 changes: 33 additions & 5 deletions src/marks/raster.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class AbstractRaster extends Mark {
y1 = y == null ? 0 : undefined,
x2 = x == null ? width : undefined,
y2 = y == null ? height : undefined,
colorSpace = "srgb",
pixelSize = defaults.pixelSize,
blur = 0,
interpolate
Expand Down Expand Up @@ -79,6 +80,7 @@ export class AbstractRaster extends Mark {
this.pixelSize = number(pixelSize, "pixelSize");
this.blur = number(blur, "blur");
this.interpolate = x == null || y == null ? null : maybeInterpolate(interpolate); // interpolation requires x & y
this.colorSpace = String(colorSpace);
}
}

Expand Down Expand Up @@ -125,15 +127,19 @@ export class Raster extends AbstractRaster {
// function, offset into the dense grid based on the current facet index.
else if (this.data == null && index) offset = index.fi * n;

// Color space and CSS4 color conversion
const colorBytes = converter(this.colorSpace);

// Render the raster grid to the canvas, blurring if needed.
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const context2d = canvas.getContext("2d");
const context2d = canvas.getContext("2d", {colorSpace: this.colorSpace});
const image = context2d.createImageData(w, h);
const imageData = image.data;
let {r, g, b} = rgb(this.fill) ?? {r: 0, g: 0, b: 0};
let a = (this.fillOpacity ?? 1) * 255;
const fo = this.fillOpacity ?? 1;
let {r, g, b, opacity: co = 1} = colorBytes(this.fill) ?? {r: 0, g: 0, b: 0};
let a = co * fo * 255;
for (let i = 0; i < n; ++i) {
const j = i << 2;
if (F) {
Expand All @@ -142,9 +148,10 @@ export class Raster extends AbstractRaster {
imageData[j + 3] = 0;
continue;
}
({r, g, b} = rgb(fi));
({r, g, b, opacity: co = 1} = colorBytes(fi));
if (!FO) a = co * fo * 255;
}
if (FO) a = FO[i + offset] * 255;
if (FO) a = co * FO[i + offset] * 255;
imageData[j + 0] = r;
imageData[j + 1] = g;
imageData[j + 2] = b;
Expand Down Expand Up @@ -502,3 +509,24 @@ function denseY(y1, y2, width, height) {
}
};
}

// Color space and CSS4 conversions
export function converter(colorSpace) {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", {colorSpace, willReadFrequently: true});
const mem = new Map();
const canvasConverter = (c) => {
if (mem.has((c = String(c)))) return mem.get(c);
context.fillStyle = c;
context.clearRect(0, 0, 1, 1);
context.fillRect(0, 0, 1, 1);
const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
const color = {r, g, b, opacity: a / 255};
if (mem.size < 256) mem.set(c, color);
return color;
};
let p;
return colorSpace === "srgb" ? (c) => (isNaN((p = rgb(c)).opacity) ? canvasConverter(c) : p) : canvasConverter;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don’t like switching to the canvasConverter in the sRGB case when the opacity is NaN… I would prefer to always use the simple rgbConvert in the sRGB case (the current behavior), and allow the user to opt-in to the canvasConverter implementation if desired (to have the browser handle color conversion), and make the canvasConverter implementation the default in the non-sRGB case. We could also expose these converter implementations similar to how we expose interpolatorBarycentric etc. This would also allow you to force “direct” (or “vivid”) conversion from RGB bytes to P3 XYZ if desired, though perhaps ill-advised.

Copy link
Copy Markdown
Contributor Author

@Fil Fil Apr 7, 2026

Choose a reason for hiding this comment

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

This converter adds support both for (a) CSS4 color strings in the sRGB space, and (b) display-p3. (The NaN check is only for additional speed in the sRGB case where d3.rgb would be faster, so we try that first, then switch to canvas as a fall-back; but it has an additional cost for all invalid color strings.)

I can see how we could want (a) to be opt-in, so that by default we only use d3.rgb, for simplicity—I'm not sure if it's better for the user but at least it would not be slower in the case where many colors are invalid strings.

I feel that the fact that we are converting the color string through canvas is an implementation detail, so I'd be wary about exposing this under the name "canvas". Maybe colorConverter: "css4" would be a better name?

And imo using this converter with sRGB should still try to parse with d3.rgb first, for speed.

PS: Re-reading the code I see that it doesn't support rgba colors, as it drops the a part. This feels wrong. (Edit: FIXED)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No, I think given the complexity it is inherently a leaky abstraction and the user can decide whether to use our limited color parser or (at the expense of performance) the canvas-based one.

}
Loading