diff --git a/src/index.js b/src/index.js index a95fdbc035..e6f5b17503 100644 --- a/src/index.js +++ b/src/index.js @@ -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"; diff --git a/src/marks/raster.d.ts b/src/marks/raster.d.ts index d9795208ae..eca37c704e 100644 --- a/src/marks/raster.d.ts +++ b/src/marks/raster.d.ts @@ -151,7 +151,16 @@ export interface RasterOptions extends Omit * * [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 @@ -258,5 +267,14 @@ export function interpolatorRandomWalk(options?: { maxSteps?: number; }): RasterInterpolateFunction; +export type ColorSpace = "srgb" | "display-p3" | (string & Record); +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 {} diff --git a/src/marks/raster.js b/src/marks/raster.js index 26e818998f..a13e30ddd9 100644 --- a/src/marks/raster.js +++ b/src/marks/raster.js @@ -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 @@ -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; } } @@ -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; @@ -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; @@ -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; } diff --git a/test/plots/index.html b/test/plots/index.html index f112d6b6f2..e3e823a234 100644 --- a/test/plots/index.html +++ b/test/plots/index.html @@ -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) => { diff --git a/test/plots/raster-penguins.ts b/test/plots/raster-penguins.ts index a7ec59eec1..e10442f66b 100644 --- a/test/plots/raster-penguins.ts +++ b/test/plots/raster-penguins.ts @@ -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("data/penguins.csv", d3.autoType); return Plot.plot({ marks: [ @@ -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) + }); });