diff --git a/docs/marks/raster.md b/docs/marks/raster.md
index 1d5e6b803c..9f1276ef9d 100644
--- a/docs/marks/raster.md
+++ b/docs/marks/raster.md
@@ -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)
* **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}
diff --git a/src/marks/raster.d.ts b/src/marks/raster.d.ts
index 8a1a9248d6..d9795208ae 100644
--- a/src/marks/raster.d.ts
+++ b/src/marks/raster.d.ts
@@ -145,6 +145,14 @@ export interface RasterOptions extends Omit
*/
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*,
diff --git a/src/marks/raster.js b/src/marks/raster.js
index 2d5662ede7..26e818998f 100644
--- a/src/marks/raster.js
+++ b/src/marks/raster.js
@@ -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
@@ -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);
}
}
@@ -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) {
@@ -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;
@@ -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;
+}
diff --git a/test/output/rasterPenguinsCSS4.svg b/test/output/rasterPenguinsCSS4.svg
new file mode 100644
index 0000000000..e49aee4372
--- /dev/null
+++ b/test/output/rasterPenguinsCSS4.svg
@@ -0,0 +1,415 @@
+
\ No newline at end of file
diff --git a/test/output/rasterVaporP3.svg b/test/output/rasterVaporP3.svg
new file mode 100644
index 0000000000..532861d367
--- /dev/null
+++ b/test/output/rasterVaporP3.svg
@@ -0,0 +1,59 @@
+
\ No newline at end of file
diff --git a/test/plots/raster-penguins.ts b/test/plots/raster-penguins.ts
index eb57c3b987..a7ec59eec1 100644
--- a/test/plots/raster-penguins.ts
+++ b/test/plots/raster-penguins.ts
@@ -27,3 +27,9 @@ test(async function rasterPenguinsRandomWalk() {
test(async function rasterPenguinsBlur() {
return rasterPenguins({interpolate: "random-walk", blur: 7});
});
+
+test(async function rasterPenguinsCSS4() {
+ // observable10 converted to oklch
+ const scale = d3.scaleOrdinal(["oklch(71.83% 0.176 30.86)", "oklch(54.8% 0.165 265.62)", "oklch(79.71% 0.16 82.35)"]);
+ return rasterPenguins({interpolate: "random-walk", fill: (d: any) => scale(d.island)});
+});
diff --git a/test/plots/raster-vapor.ts b/test/plots/raster-vapor.ts
index e158cf5234..d805986baf 100644
--- a/test/plots/raster-vapor.ts
+++ b/test/plots/raster-vapor.ts
@@ -63,6 +63,24 @@ test(async function contourVapor() {
});
});
+test(async function rasterVaporP3() {
+ return Plot.plot({
+ x: {transform: (x) => x - 180},
+ y: {transform: (y) => 90 - y},
+ color: {
+ type: "sqrt",
+ interpolate: (t: number) => `oklch(50% 0.25 ${220 + t * 140}deg / ${0.5 + t * 0.5})`
+ },
+ marks: [
+ Plot.raster(await vapor(), {
+ width: 360,
+ height: 180,
+ colorSpace: "display-p3"
+ })
+ ]
+ });
+});
+
test(async function contourVaporClip() {
const [world, data] = await Promise.all([d3.json("data/countries-50m.json"), vapor()]);
const land = feature(world, world.objects.land);