Skip to content
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
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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 {Raster, raster} from "./marks/raster.js";
export {Raster, raster, colorParser, colorCanvas} from "./marks/raster.js";
export {interpolateNone, interpolatorBarycentric, interpolateNearest, interpolatorRandomWalk} from "./marks/raster.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
Expand Down
20 changes: 19 additions & 1 deletion src/marks/raster.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,16 @@ export interface RasterOptions extends Omit<MarkOptions, "fill" | "fillOpacity">
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace
*/
colorSpace?: "srgb" | "display-p3" | string;
colorSpace?: ColorSpace;

/**
* How color strings are converted into image data values for use with the
* underlying canvas; typically each value is an integer in [0, 255]. If the
* colorSpace is sRGB, defaults to colorParser, a fast d3-color implementation
* that only supports CSS3 color strings; otherwise defaults to the slower but
* more complete colorCanvas implementation.
*/
colorConverter?: ColorConverter;

/**
* The fill, typically bound to the *color* scale. Can be specified as a
Expand Down Expand Up @@ -258,5 +267,14 @@ export function interpolatorRandomWalk(options?: {
maxSteps?: number;
}): RasterInterpolateFunction;

export type ColorSpace = "srgb" | "display-p3" | (string & Record<never, never>);
export type ColorConverter = (color: string) => [r: number, g: number, b: number, a: number];

/** Converts the given color string to RGBA using d3-color; only supports CSS3. */
export const colorParser: ColorConverter;

/** Converts the given color string to RGBA using a Canvas 2D context. */
export const colorCanvas: (colorSpace?: ColorSpace) => ColorConverter;

/** The raster mark. */
export class Raster extends RenderableMark {}
46 changes: 21 additions & 25 deletions src/marks/raster.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class AbstractRaster extends Mark {
x2 = x == null ? width : undefined,
y2 = y == null ? height : undefined,
colorSpace = "srgb",
colorConverter,
pixelSize = defaults.pixelSize,
blur = 0,
interpolate
Expand Down Expand Up @@ -80,7 +81,8 @@ 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);
this.colorSpace = String(colorSpace).toLowerCase();
this.colorConverter = colorConverter === undefined ? getDefaultColorConverter(this.colorSpace) : colorConverter;
}
}

Expand Down Expand Up @@ -127,9 +129,6 @@ 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;
Expand All @@ -138,20 +137,16 @@ export class Raster extends AbstractRaster {
const image = context2d.createImageData(w, h);
const imageData = image.data;
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;
let [r, g, b, co] = this.colorConverter(this.fill);
let a = co * fo;
for (let i = 0; i < n; ++i) {
const j = i << 2;
if (F) {
const fi = color(F[i + offset]);
if (fi == null) {
imageData[j + 3] = 0;
continue;
}
({r, g, b, opacity: co = 1} = colorBytes(fi));
if (!FO) a = co * fo * 255;
[r, g, b, co] = this.colorConverter(fi); // TODO memoize?
if (!FO) a = co * fo;
}
if (FO) a = co * FO[i + offset] * 255;
if (FO) a = co * FO[i + offset];
imageData[j + 0] = r;
imageData[j + 1] = g;
imageData[j + 2] = b;
Expand Down Expand Up @@ -510,23 +505,24 @@ function denseY(y1, y2, width, height) {
};
}

// Color space and CSS4 conversions
export function converter(colorSpace) {
function getDefaultColorConverter(colorSpace) {
return colorSpace === "srgb" ? colorParser : colorCanvas(colorSpace);
}

export function colorParser(color) {
const c = rgb(color);
return c ? [c.r, c.g, c.b, c.opacity * 255] : [0, 0, 0, 0];
}

export function colorCanvas(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;
return (color) => {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
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;
return context.getImageData(0, 0, 1, 1).data;
};
let p;
return colorSpace === "srgb" ? (c) => (isNaN((p = rgb(c)).opacity) ? canvasConverter(c) : p) : canvasConverter;
}
14 changes: 8 additions & 6 deletions test/plots/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@
};

select.append(
...Object.keys(tests).map((key) => {
const option = document.createElement("option");
option.value = key;
option.textContent = key;
return option;
})
...Object.keys(tests)
.sort()
.map((key) => {
const option = document.createElement("option");
option.value = key;
option.textContent = key;
return option;
})
);

addEventListener("popstate", (event) => {
Expand Down
8 changes: 6 additions & 2 deletions test/plots/raster-penguins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {test} from "test/plot";

async function rasterPenguins(options) {
async function rasterPenguins(options: Plot.RasterOptions) {
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
return Plot.plot({
marks: [
Expand Down Expand Up @@ -31,5 +31,9 @@ test(async function rasterPenguinsBlur() {
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)});
return rasterPenguins({
interpolate: "random-walk",
colorConverter: Plot.colorCanvas(),
fill: (d: any) => scale(d.island)
});
});