Public
Edited
Apr 7
1 fork
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
luv_chromaticity_chart = make_luv_chromaticity_chart(svg_width, settings1)
Insert cell
populateDisplay = {
// div with fixed aspect ratio
var div = html`<div style="position: relative; width: 100%; height: auto; aspect-ratio: 1;">`;

// we are unable to reliably display an image with a given color space in an SVG element.
// populating the image would also be quite slow. (it requires encoding the image to a
// blob or a data URL)
// So we will show a canvas with the SVG on top of it, and the SVG file has a transparent
// hole inside the gamut.
const {xscale, yscale} = luv_chromaticity_chart;
uv_canvas1.canvas.style.position = "absolute";
uv_canvas1.canvas.style.left = xscale(raster_extent.x) / svg_width * 100 + "%";
uv_canvas1.canvas.style.top = yscale(raster_extent.y + raster_extent.h) / svg_width * 100 + "%";
uv_canvas1.canvas.style.width = (xscale(raster_extent.x + raster_extent.w) - xscale(raster_extent.x)) / svg_width * 100 + "%";
uv_canvas1.canvas.style.height = (yscale(raster_extent.y) - yscale(raster_extent.y + raster_extent.h)) / svg_width * 100 + "%";

var f = settings1.bg_fill;
var bg_color = colorToCss(sRGB([f, f, f]));
div.style.background = bg_color;
div.appendChild(uv_canvas1.canvas);

var svg = luv_chromaticity_chart.node;
svg.style.position = 'absolute';
svg.style.width = "100%";
svg.style.height = "100%";
div.appendChild(svg);
display.replaceChildren(div);
}
Insert cell
function get_canvas_with_image() {
var image_data_url = uv_canvas1.canvas.toDataURL();
var svg = make_luv_chromaticity_chart(600, {...settings1, image_url: image_data_url});

var downloadLink = document.createElement('a');
var name = 'luv.svg';
downloadLink.setAttribute('download', name);
svg.node.setAttribute("xmlns", "http://www.w3.org/2000/svg");
var svgBlob = new Blob(['<?xml version="1.0"?>\n', svg.node.outerHTML], {type:"image/svg+xml;charset=utf-8"});
var svgUrl = URL.createObjectURL(svgBlob);
downloadLink.setAttribute('href', svgUrl);
downloadLink.click();
URL.revokeObjectURL(svgUrl);
};
Insert cell
{
// warning: make sure the input is regenerated (or reset to 0) on updates
if (!svg_button) return "Click button to get SVG";

get_canvas_with_image();
return "Got SVG"
}
Insert cell
colorSpaceDisplayHaveNote = false
Insert cell
function make_luv_chromaticity_chart(chart_size, settings) {
// scale
const graph_size = chart_size - 1;
// scale is approximately 2/3, but even with SVG it will look terrible if you ignore physical pixels:
// grid lines are at 0.1 intervals, make sure this interval is an integer number of pixels.
const appx_graph_range = 2/3;
const grid_spacing = 0.1;
const grid_px = Math.round(graph_size / appx_graph_range * grid_spacing);
const px_dx = grid_spacing / grid_px;
const graph_range = px_dx * graph_size;

const appx_graph_offset = 0.03;
const offset = Math.round(appx_graph_offset / px_dx) * px_dx;

const xscale = d3.scaleLinear()
.domain([-offset, graph_range - offset])
.range([0, graph_size]);

const yscale = d3.scaleLinear()
.domain([graph_range - offset, -offset])
.range([0, graph_size]);
const white_text = settings.bg_fill < 0.15;

var f = settings.bg_fill;
var bg_color = colorToCss(sRGB([f, f, f]));
// create SVG
const svg = d3.create("svg")
.attr("viewBox", [0, 0, chart_size, chart_size]);
var chart = { node: svg.node(), xscale: xscale, yscale: yscale, white_text: white_text };
chart.defs = svg.append("defs");

if (settings.image_url) {
// we got an image, use it as fill pattern for the gamut outline
var pat = chart.defs.append('pattern')
.attr('id', 'uv_gamut_fill')
.attr('patternUnits', 'userSpaceOnUse')
.attr("x", 0)
.attr("y", 0)
.attr("width", graph_size)
.attr("height", graph_size);
var pat_img = pat.append("image")
.attr("x", xscale(raster_extent.x))
.attr("y", yscale(raster_extent.y + raster_extent.h))
.attr("width", xscale(raster_extent.x + raster_extent.w) - xscale(raster_extent.x))
.attr("height", yscale(raster_extent.y) - yscale(raster_extent.y + raster_extent.h))
.attr("href", settings.image_url);
}

// svg path for our gamut ("d" attribute)
var line_gamut = d3.line()
.x(d => xscale(d.u))
.y(d => yscale(d.v))
.curve(d3.curveLinearClosed);
const uv_d = line_gamut(uv_data);

// gamut background color with hole
svg.append("path")
.attr('fill', bg_color)
.attr('stroke-width', '0')
.attr("d", `M0,0 L0,${chart_size} L${chart_size},${chart_size} L${chart_size},0 Z` +
(settings.image_url ? "" : ' ' + uv_d));
var g = svg.append("g");
// gamut outline + fill
chart.contour = g.append("path")
.attr('stroke', 'black')
.attr('strokewidth', '2')
.attr('fill', settings.image_url ? 'url(#uv_gamut_fill)' : 'transparent')
.attr("d", uv_d);

// sRGB gamut
var line = d3.line()
.x(d => xscale(d[0]))
.y(d => yscale(d[1]))
.curve(d3.curveLinearClosed);

const rgbM = RgbColorM.rgbToXyz;
var rgb_uv = [
XYZ_to_Luv_uv(apply_m(rgbM, [1, 0, 0])),
XYZ_to_Luv_uv(apply_m(rgbM, [0, 1, 0])),
XYZ_to_Luv_uv(apply_m(rgbM, [0, 0, 1]))];
var rgb_uv_dots = [
XYZ_to_Luv_uv(apply_m(rgbM, [1, 0, 0])),
XYZ_to_Luv_uv(apply_m(rgbM, [1, 1, 0])),
XYZ_to_Luv_uv(apply_m(rgbM, [0, 1, 0])),
XYZ_to_Luv_uv(apply_m(rgbM, [0, 1, 1])),
XYZ_to_Luv_uv(apply_m(rgbM, [0, 0, 1])),
XYZ_to_Luv_uv(apply_m(rgbM, [1, 0, 1])),
XYZ_to_Luv_uv(apply_m(rgbM, [1, 1, 1]))];

var g_gamut = g.append("g");

// gamut triangle
g_gamut.append("path")
.attr('fill', 'transparent')
.attr('stroke', 'white')
.attr('strokewidth', 1)
.attr('stroke-opacity', .5)
.attr("d", line(rgb_uv));

// group to update marks
chart.g_mark = g_gamut.append("g");

// gamut corner dots and white point
g_gamut.append("g")
.selectAll("circle")
.data(rgb_uv_dots)
.join("circle")
.attr('fill', 'transparent')
.attr('stroke', 'white')
.attr('strokewidth', 1)
.attr('stroke-opacity', .5)
.attr("r", 2.5)
.attr("cx", d => xscale(d[0]))
.attr("cy", d => yscale(d[1]));
// dots at certain wavelengths
g.append("g")
.selectAll("circle")
.data(uv_points)
.join("circle")
.attr("fill", "black")
.attr("stroke", "white")
.attr("stroke-width", 1.5)
.attr("stroke-opacity", .5)
.attr("r", 2.5)
.attr("cx", d => xscale(d.u))
.attr("cy", d => yscale(d.v));
// text labels
chart.text_g = g.append("g")
.attr("style", white_text ? "fill: #aaa"
: "text-shadow: rgba(255, 255, 255, .3) 1px 1px 1px; fill: black;")
.attr("font-size", 12)
.attr("text-anchor", "middle")
.attr("font-family", "sans-serif")
.selectAll("text")
.data(uv_points.filter(d => !d.notext))
.join("text")
.text(d => `${d.wl}`)
.attr("x", d => xscale(d.u) + d.nu * 9 - Math.max(0, Math.min(8, 535 - d.wl)))
.attr("y", d => yscale(d.v) - d.nv * 9 + 3);

// axes
g.append("g").attr("transform", `translate(0, ${yscale(0)})`)
.call(d3.axisBottom().offset(.5).scale(xscale).ticks(6)
.tickFormat(""))
.call(g => g.selectAll("line, path")
.attr("stroke", "white")
.attr("stroke-opacity", .5));
g.append("g").attr("transform", `translate(${xscale(0)}, 0)`)
.call(d3.axisLeft().offset(.5).scale(yscale).ticks(6)
.tickFormat(""))
.call(g => g.selectAll("line, path")
.attr("stroke", "white")
.attr("stroke-opacity", .5));
// grid
g.append("g").attr("transform", `translate(0, ${graph_size})`)
.call(d3.axisTop().offset(.5).scale(xscale).ticks(6)
.tickSize(graph_size)
.tickFormat(""))
.call(g => g.selectAll(".tick line")
.attr("stroke", "white")
.attr("stroke-opacity", 0.2))
.call(g => g.selectAll(".domain")
.attr("stroke", "none"));
g.append("g").attr("transform", `translate(0, 0)`)
.call(d3.axisRight().offset(.5).scale(yscale).ticks(6)
.tickSize(graph_size)
.tickFormat(""))
.call(g => g.selectAll(".tick line")
.attr("stroke", "white")
.attr("stroke-opacity", 0.2))
.call(g => g.selectAll(".domain")
.attr("stroke", "none"));

return chart;
}
Insert cell
update_mark = {
var uv_dots = []
var uv_paths = []
if (markSettings.show_mark) {
var [mx, my] = markSettings.mark;
if (markSettings.space == "xy") [mx, my] = XYZ_to_Luv_uv([mx, my, 1 - mx - my]);
uv_dots.push([mx, my, null]);
}

if (markSettings.show.includes("bb")) {
uv_dots = uv_dots.concat(
[ [ 0.44796288, 0.53194444, "1000K", 9],
[ 0.35790039, 0.54049637, "1500K", 9],
[ 0.30504404, 0.53859917, "2000K", -1],
[ 0.27764904, 0.53286075, "2400K", 0],
[ 0.26249639, 0.52735122, "2700K", 3],
[ 0.250569 , 0.5213849 , "3000K", 6],
[ 0.23571102, 0.51126179, "3500K", 5],
[ 0.22510967, 0.50158105, "4000K", 6],
[ 0.21142383, 0.48467368, "5000K", 2],
[ 0.20044859, 0.46554256, "6500K"],
[ 0.19462429, 0.45211456, "8000K"],
[ 0.1903185 , 0.43989704, "10,000K"],
[ 0.18388449, 0.41563411, "20,000K"],
[ 0.18006975, 0.39531595, "∞K"]]);
uv_paths.push([[0.1801, 0.3953], [0.1808, 0.3995], [0.1816, 0.4043], [0.1827, 0.4098], [0.1839, 0.4159],
[0.1855, 0.4227], [0.1874, 0.4300], [0.1897, 0.4379], [0.1924, 0.4461], [0.1957, 0.4547],
[0.1995, 0.4635], [0.2039, 0.4723], [0.2090, 0.4810], [0.2149, 0.4895], [0.2215, 0.4977],
[0.2289, 0.5054], [0.2373, 0.5125], [0.2466, 0.5190], [0.2569, 0.5247], [0.2682, 0.5297],
[0.2806, 0.5337], [0.2941, 0.5368], [0.3088, 0.5391], [0.3248, 0.5404], [0.3421, 0.5408],
[0.3606, 0.5404], [0.3805, 0.5392], [0.4018, 0.5373], [0.4243, 0.5349], [0.4480, 0.5319],
[0.4726, 0.5286]
]);
}

if (markSettings.show.includes("d")) {
uv_dots = uv_dots.concat(
[ [ 0.25596937, 0.52429072, "A" ],
[ 0.22353376, 0.50487858, "D40" ],
[ 0.20911366, 0.48812190, "D50" ],
[ 0.20439325, 0.48079525, "D55" , 2],
[ 0.19779707, 0.46838996, "D65" ],
[ 0.19350552, 0.45858085, "D75" ],
[ 0.18878269, 0.44570859, "D93" ],
[ 0.18502301, 0.43346872, "D120" ]])
uv_paths.push([[0.2235, 0.5049], [0.2178, 0.4990], [0.2130, 0.4934], [0.2091, 0.4881], [0.2074, 0.4856],
[0.2058, 0.4832], [0.2044, 0.4808], [0.2018, 0.4763], [0.1997, 0.4722], [0.1978, 0.4684],
[0.1962, 0.4649], [0.1948, 0.4616], [0.1935, 0.4586], [0.1916, 0.4537], [0.1901, 0.4494],
[0.1888, 0.4457], [0.1872, 0.4409], [0.1860, 0.4369], [0.1850, 0.4335]]);
}

const xscale = luv_chromaticity_chart.xscale;
const yscale = luv_chromaticity_chart.yscale;

luv_chromaticity_chart.g_mark
.selectAll("circle")
.data(uv_dots)
.join("circle")
.attr('fill', 'transparent')
.attr('stroke', 'white')
.attr('strokewidth', 1)
.attr("r", 2.5)
.attr("cx", d => xscale(d[0]))
.attr("cy", d => yscale(d[1]));

var line_gamut = d3.line()
.x(uv => xscale(uv[0]))
.y(uv => yscale(uv[1]))
luv_chromaticity_chart.g_mark
.selectAll("path")
.data(uv_paths)
.join("path")
.attr('fill', 'transparent')
.attr('stroke', 'white')
.attr('strokewidth', 1)
.attr('stroke-opacity', .4)
.attr('d', line_gamut);

luv_chromaticity_chart.g_mark
.attr("fill", "white")
.attr("font-size", 9)
.attr("font-weight", "bold")
.attr("text-anchor", "end")
.attr("font-family", "sans-serif")
.selectAll("text")
.data(uv_dots.filter(d => d[2]))
.join("text")
.text(d => `${d[2]}`)
.attr("x", d => xscale(d[0]) - 5)
.attr("y", d => yscale(d[1]) - 2 + (d[3] || 0));
}
Insert cell
svg_width = Math.min(width - 10, 700)
Insert cell
## Fill
Insert cell
canvas_width = 250
Insert cell
// which rectangle is covered by our gamut raster
raster_extent = ({x: -0.01, y: -0.01, w: 0.64, h: 0.64});
Insert cell
uv_canvas1 = color_map_gamut(settings1)
Insert cell
Insert cell
// Map the RGB values from our rasterized gamut to display colors
function color_map_gamut(settings)
{
var {
bg_fill = 0.25,
rgb_norm = 1.00,
norm_method = "RGB 4-norm",
clip_first = false,
clip_method = "Mix grey"} = settings;
const resolution = canvas_width;
rgb_norm *= (1 - bg_fill);
const canvas = DOM.canvas(resolution, resolution);
const ctx = canvas.getContext("2d", {colorSpace: colorSpace});
const img_data = ctx.createImageData(resolution, resolution);

var data_offset = 0;
for (var p_y = 0 ; p_y < resolution; ++p_y)
{
for (var p_x = 0 ; p_x < resolution; ++p_x, data_offset += 4)
{
if (gamut_raster[data_offset + 3] == 0) continue;
var rgb1 = [gamut_raster[data_offset],
gamut_raster[data_offset + 1],
gamut_raster[data_offset + 2]];

var rgb;
switch (norm_method)
{
case "RGB 4-norm":
rgb = normalize_4norm(rgb1, rgb_norm);
break;
case "RGB 2-norm":
rgb = normalize_2norm(rgb1, rgb_norm);
break;
case "RGB max":
rgb = normalize_infnorm(rgb1, rgb_norm);
break;
case "Luminance":
rgb = normalize_lum(rgb1, rgb_norm * 0.10);
break;
case "Reflectance":
{
rgb = normalize_lum(rgb1, rgb_norm * max_reflectance_lut[data_offset]);
break;
}
case "Power":
{
rgb = normalize_lum(rgb1, rgb_norm * 0.75 * max_power_lut[data_offset]);
break;
}
}

if (!clip_first) rgb = rgb.map(x => x + bg_fill);

switch (clip_method)
{
case "Mix grey": rgb = rgbGreyDesaturate(rgb, {grey: 0}); break;
case "Clip": rgb = rgb.map(d => Math.max(0, d)); break;
case "Perceptual" : rgb = perceptualAutoDesat(rgb, {grey: 0, amount: 0, auto: true}); break;
case "Perceptual/desat" : rgb = perceptualAutoDesat(rgb, {grey: 0, amount: 0.15, auto: true}); break;
default: rgb = gamutAlert(rgb, p_x + p_y);
}
if (clip_first) rgb = rgb.map(x => x + bg_fill);

rgb = sRGB(rgb);
img_data.data[data_offset] = rgb[0] * 255;
img_data.data[data_offset + 1] = rgb[1] * 255;
img_data.data[data_offset + 2] = rgb[2] * 255;
img_data.data[data_offset + 3] = 255;
}
}

ctx.putImageData(img_data, 0, 0);
return {canvas: ctx.canvas};
}
Insert cell
// Rasterize LUV gamut as one polygon, one column at a time starting form the left (minimum value of u′).
// we don’t strictly have to limit ourselves to pixels inside the gamut but it makes the output look
// less weird.
// We do add (mostly) one pixel of padding outside the gamut, so if we use this raster to fill up a polygon,
// we will not see jagged edges at the border where it interpolates with background pixels. This lets us get
// away with a much lower resolution.
// the output color is linear so this will look quite saturated by itself.
gamut_raster = {
var bg_fill = 0.50;
var rgb_norm = 0.49;
function intercept(u, index1, index2)
{
var u1 = uv_data[index1].u;
var u2 = uv_data[index2].u;
var t = (u - u1) / (u2 - u1);
return (1 - t) * uv_data[index1].v + t * uv_data[index2].v;
}
var resolution = canvas_width;
const data = new Float32Array(4 * canvas_width * canvas_width);
const data_y_pitch = 4 * canvas_width;
const p_scale = raster_extent.w / resolution;
const p_offset = raster_extent.x;
const p_inv_scale = 1 / p_scale;
const p_inv_offset = -p_offset * p_inv_scale;

// 1 extra pixel here
const p_x_first = Math.round(uv_data[uv_min_u_index].u * p_inv_scale + p_inv_offset) - 1;
var p_x = p_x_first;
var blue_segment = uv_min_u_index;
var red_segment = uv_min_u_index - 1;
for ( ; p_x < resolution; ++p_x)
{
// u, v
var u = (p_x + 0.5) * p_scale + p_offset;

// for the first few columns, shift u so we get some padding on that leftmost bend
var u1 = u;
if (p_x < p_x_first + 2) u1 += 0.8 * p_scale;

// find segments
while (red_segment + 1 < uv_data.length &&
u1 >= uv_data[red_segment + 1].u) {
++red_segment;
}
if (red_segment + 1 == uv_data.length) {
break;
}
while (blue_segment > 0 &&
u1 >= uv_data[blue_segment].u) {
--blue_segment;
}

// intercepts (the last segment where blue_segment wraps around is the purple line)
var intercept_a = intercept(u1, (blue_segment > 0 ? blue_segment : uv_data.length) - 1, blue_segment);
var intercept_b = intercept(u1, red_segment, red_segment + 1);
var p_a = Math.round(intercept_a * p_inv_scale + p_inv_offset) - 1; // 1 extra pixel at the top and bottom
var p_b = Math.round(intercept_b * p_inv_scale + p_inv_offset) + 1;

const M = RgbColorM.xyzToRgb;
// finally, rasterize line
for (var p_y = p_a ; p_y < p_b; ++p_y)
{
var v = (p_y + 0.5) * p_scale + p_offset;
var xyz = Luv_uv_to_XYZ([u, v]);
var rgb = apply_m(M, xyz);
var ofs = data_y_pitch * (resolution - p_y - 1) + 4 * p_x;
data[ofs] = rgb[0];
data[ofs + 1] = rgb[1];
data[ofs + 2] = rgb[2];
data[ofs + 3] = 1;
}
}
return data;
}
Insert cell
Insert cell
Insert cell
max_power_img = await FileAttachment("Luv-maxpower-lut@1.png").image()
Insert cell
Insert cell
max_power_lut = image_as_data(max_power_img)
Insert cell
Insert cell
max_reflectance_img = await FileAttachment("Luv-maxreflectance-lut@1.png").image()
Insert cell
Insert cell
max_reflectance_lut = image_as_data(max_reflectance_img)
Insert cell
async function image_as_data(img)
{
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0 );
const data8 = ctx.getImageData(0, 0, img.width, img.height).data;
const data = new Float32Array(data8.length);
for (var i = 0; i < data8.length; ++i)
{
data[i] = inv_sRGB(data8[i] / 255);
}
return data;
}
Insert cell
function inv_sRGB(x)
{
return x > 0.04045 ? Math.pow((x + 0.055) / 1.055, 2.4) : x / 12.92;
}
Insert cell
// XYZ to u′v′
function XYZ_to_Luv_uv([x, y, z])
{
if (x == 0 && y == 0 && z == 0) {
return [4 / 19, 9 / 19];
}
var u1 = 4 * x / (x + 15 * y + 3 * z);
var v1 = 9 * y / (x + 15 * y + 3 * z);
return [u1, v1];
}
Insert cell
// u′v′ to XYZ
// This starts only with a chromaticity, so we have to pick some arbitrary scale. Here the sum
// x + y + z will be 1.0, so the first two coordinates are the xy coordinate.
function Luv_uv_to_XYZ([u, v])
{
var x = 9 * u / (6 * u - 16 * v + 12);
var y = 4 * v / (6 * u - 16 * v + 12);
var z = 1 - x - y;
return [x, y, z];
}
Insert cell
function normalize_4norm([r, g, b], norm)
{
// normalize
var scale = norm / Math.pow(r*r*r*r + g*g*g*g + b*b*b*b, .25);
return [r * scale, g * scale, b * scale];
}
Insert cell
function normalize_2norm([r, g, b], norm)
{
// normalize
var scale = norm / Math.sqrt(r*r + g*g + b*b);
return [r * scale, g * scale, b * scale];
}
Insert cell
function normalize_infnorm([r, g, b], norm) {
// normalize
var scale = norm / Math.max(Math.abs(r), Math.abs(g), Math.abs(b));
return [r * scale, g * scale, b * scale];
}
Insert cell
function normalize_lum([r, g, b], norm)
{
const [m21, m22, m23] = RgbColorM.rgbToXyz[1];
var scale = norm / (m21 * r + m22 * g + m23 * b);
return [r * scale, g * scale, b * scale];
}
Insert cell
uv_data = {
var data = [];
var rgbM = RgbColorM.xyzToRgb;
for (var i = 0; i < cie1931.x.length; ++i)
{
var xyz = [cie1931.x[i], cie1931.y[i], cie1931.z[i]]
var [u, v] = XYZ_to_Luv_uv(xyz);
var [r, g, b] = apply_m(rgbM, xyz);
data.push({wl: i+380, x:xyz[0], y:xyz[1], z:xyz[2], u:u, v:v, r:r, g:g, b:b});
}
return data;
}
Insert cell
function uv_at_wl(wl) { return {...uv_data[wl - 380]}; }
Insert cell
uv_points = {
var data = [];
data.push(uv_at_wl(400));
for (var i = 430; i < 660; i += 10) {
data.push(uv_at_wl(i));
}
data.push(uv_at_wl(700));
data[data.length - 2].notext = true;
data[data.length - 4].notext = true;

// vectors for text placement
var u0, v0;
data.forEach(d => {
if (d.wl > 420)
{
var Δu = -(d.v - v0);
var Δv = (d.u - u0);
var len = Math.hypot(Δu, Δv);
d.nu = Δu / len;
d.nv = Δv / len;
}
else {
d.nu = 0;
d.nv = -1.4;
}
u0 = d.u;
v0 = d.v;
})
return data;
}
Insert cell
uv_min_u_index = {
var max_i = 100;
while (uv_data[max_i].u > uv_data[max_i + 1].u) ++max_i;
return max_i;
}
Insert cell
function gamutAlert(rgb, offset)
{
const [r, g, b] = rgb;
if (Math.min(r, g, b) < -0.0001) {
return offset % 4 < 2 ? [.5, .5, .5] : [0, 0, 0];
}
else if (Math.max(r, g, b) > 1.0001) {
return offset % 4 < 2 ? [.5, .5, .5] : [1, 1, 1];
}
else {
return rgb;
}
}
Insert cell
import {cie1931, viewof colorSpaceChoice, colorSpace, colorSpaceDisplay, rgbGreyDesaturate, OklabSaturate, perceptualAutoDesat, apply_m, RgbColorM, OklabLmsRgbM, sRGB, colorToCss}
with {colorSpaceDisplayHaveNote as colorSpaceDisplayHaveNote}
from "@roelandschoukens/rendering-a-spectrum"
Insert cell
import {multiChannel} from "@roelandschoukens/inputs"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more