Skip to content

Commit 2a2ef29

Browse files
mbostockFil
andauthored
raster colorSpace (#2143)
* raster colorSpace * use canvas for color space conversion (#2417) * Use context2D to convert CSS4 colors to display-p3 * * use the canvas converter for colors that d3.rgb can't parse (CSS4) * memoize (with some decent limit) for faster renders * clear rect to guard against semi-transparent color leaks * support opacity in literal colors * fix test * prestier * colorConverter option * need colorCanvas to use oklch * checkpoint * remove colorConverter option * memoize color scale, too --------- Co-authored-by: Philippe Rivière <fil@rezo.net> * handle null color * revert color scale memoization * remove unused import; fix test script * defer colorConverter construction * regenerate snapshots again? * fix constant fill logic --------- Co-authored-by: Philippe Rivière <fil@rezo.net>
1 parent cf72691 commit 2a2ef29

10 files changed

Lines changed: 554 additions & 28 deletions

File tree

docs/marks/raster.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,12 @@ If **width** is specified, **x1** defaults to 0 and **x2** defaults to **width**
286286

287287
The following raster-specific constant options are supported:
288288

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

293-
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*.
294+
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*.
294295

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"src/**/*.js"
2828
],
2929
"scripts": {
30-
"test": "pnpm run test:vitest && pnpm run test:tsc && pnpm run test:lint && pnpm run test:prettier",
30+
"test": "pnpm run test:vitest run && pnpm run test:tsc && pnpm run test:lint && pnpm run test:prettier",
3131
"test:coverage": "TZ=America/Los_Angeles vitest run --coverage",
3232
"test:vitest": "TZ=America/Los_Angeles vitest --typecheck",
3333
"test:lint": "eslint src test",

src/marks/raster.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ export interface RasterOptions extends Omit<MarkOptions, "fill" | "fillOpacity">
145145
*/
146146
imageRendering?: string;
147147

148+
/**
149+
* The [color space][1] of the backing canvas. Defaults to *srgb*; set to
150+
* *display-p3* for the Display P3 color space.
151+
*
152+
* [1]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace
153+
*/
154+
colorSpace?: ImageData["colorSpace"];
155+
148156
/**
149157
* The fill, typically bound to the *color* scale. Can be specified as a
150158
* constant, a channel based on the sample *data*, or as a function *f*(*x*,

src/marks/raster.js

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {blurImage, Delaunay, randomLcg, rgb} from "d3";
1+
import {blurImage, Delaunay, randomLcg} from "d3";
22
import {valueObject} from "../channel.js";
33
import {create} from "../context.js";
44
import {map, first, second, third, isTuples, isNumeric, isTemporal, identity} from "../options.js";
@@ -36,6 +36,7 @@ export class AbstractRaster extends Mark {
3636
y1 = y == null ? 0 : undefined,
3737
x2 = x == null ? width : undefined,
3838
y2 = y == null ? height : undefined,
39+
colorSpace = "srgb",
3940
pixelSize = defaults.pixelSize,
4041
blur = 0,
4142
interpolate
@@ -79,6 +80,7 @@ export class AbstractRaster extends Mark {
7980
this.pixelSize = number(pixelSize, "pixelSize");
8081
this.blur = number(blur, "blur");
8182
this.interpolate = x == null || y == null ? null : maybeInterpolate(interpolate); // interpolation requires x & y
83+
this.colorSpace = String(colorSpace).toLowerCase();
8284
}
8385
}
8486

@@ -126,29 +128,23 @@ export class Raster extends AbstractRaster {
126128
else if (this.data == null && index) offset = index.fi * n;
127129

128130
// Render the raster grid to the canvas, blurring if needed.
131+
const colorConverter = (this.colorConverter ??= getColorConverter(this.colorSpace, context));
129132
const canvas = document.createElement("canvas");
130133
canvas.width = w;
131134
canvas.height = h;
132-
const context2d = canvas.getContext("2d");
135+
const context2d = canvas.getContext("2d", {colorSpace: this.colorSpace});
133136
const image = context2d.createImageData(w, h);
134137
const imageData = image.data;
135-
let {r, g, b} = rgb(this.fill) ?? {r: 0, g: 0, b: 0};
136-
let a = (this.fillOpacity ?? 1) * 255;
138+
let rgba = colorConverter(this.fill ?? "black");
139+
let a = this.fillOpacity ?? 1;
137140
for (let i = 0; i < n; ++i) {
138141
const j = i << 2;
139-
if (F) {
140-
const fi = color(F[i + offset]);
141-
if (fi == null) {
142-
imageData[j + 3] = 0;
143-
continue;
144-
}
145-
({r, g, b} = rgb(fi));
146-
}
147-
if (FO) a = FO[i + offset] * 255;
148-
imageData[j + 0] = r;
149-
imageData[j + 1] = g;
150-
imageData[j + 2] = b;
151-
imageData[j + 3] = a;
142+
if (F) rgba = colorConverter(color(F[i + offset]));
143+
if (FO) a = FO[i + offset];
144+
imageData[j + 0] = rgba[0];
145+
imageData[j + 1] = rgba[1];
146+
imageData[j + 2] = rgba[2];
147+
imageData[j + 3] = rgba[3] * a;
152148
}
153149
if (this.blur > 0) blurImage(image, this.blur);
154150
context2d.putImageData(image, 0, 0);
@@ -502,3 +498,24 @@ function denseY(y1, y2, width, height) {
502498
}
503499
};
504500
}
501+
502+
const transparent = new Uint8ClampedArray(4);
503+
504+
function getColorConverter(colorSpace, {document}) {
505+
const cache = new Map();
506+
const canvas = document.createElement("canvas");
507+
canvas.width = 1;
508+
canvas.height = 1;
509+
const context = canvas.getContext("2d", {colorSpace, willReadFrequently: true});
510+
return (color) => {
511+
if (color == null) return transparent;
512+
let data = cache.get(color);
513+
if (data !== undefined) return data;
514+
context.clearRect(0, 0, 1, 1);
515+
context.fillStyle = color;
516+
context.fillRect(0, 0, 1, 1);
517+
data = context.getImageData(0, 0, 1, 1).data;
518+
cache.set(color, data);
519+
return data;
520+
};
521+
}

0 commit comments

Comments
 (0)