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 @@ + + + + + 175 + 180 + 185 + 190 + 195 + 200 + 205 + 210 + 215 + 220 + 225 + 230 + + + ↑ flipper_length_mm + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + −80 + −60 + −40 + −20 + 0 + 20 + 40 + 60 + 80 + + + + −150 + −100 + −50 + 0 + 50 + 100 + 150 + + + + + \ 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);