Public
Edited
Jan 29
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
async function make_luv_chromaticity_chart(chart_size, settings, image_url) {

chart_size = Math.min(width, chart_size);
// 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;

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

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

var f = settings.bg_fill;
var bg_color = colorToCss(sRGB([f, f, f]));
// create SVG
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, chart_size]);
var chart = { node:svg.node() };
chart.defs = svg.append("defs");
// gamut image pattern
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", image_url);
var g = svg.append("g");

// gamut background color
g.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", graph_size)
.attr("height", graph_size)
.attr('fill', bg_color);
// gamut outline + fill
var line_gamut = d3.line()
.x(d => xscale(d.u))
.y(d => yscale(d.v))
.curve(d3.curveLinearClosed);
chart.contour = g.append("path")
.attr('stroke', 'black')
.attr('strokewidth', '2')
.attr('fill', 'url(#uv_gamut_fill)')
.attr("d", line_gamut(uv_data));

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

const { redWL, greenWL, blueWL } = gamutVertices;
var rgb_uv = [
uv_at_wl(redWL),
uv_at_wl(greenWL),
uv_at_wl(blueWL)
].map(({u,v})=>[u,v]);

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

// gamut triangle
g_gamut.append("path")
.attr('fill', 'transparent')
.attr('stroke', 'white')
.attr('stroke-width', 2)
.attr('stroke-opacity', 1)
.attr("d", line(rgb_uv));
// 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("fill", "white")
.attr("style", "text-shadow: rgba(40, 40, 40, .3) 1px 1px 1px")
.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().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().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()
.scale(xscale).ticks(6)
.tickSize(graph_size)
.tickFormat(""))
.call(g => g.selectAll(".tick line")
.attr("stroke", "white")
.attr("stroke-opacity", 0.2));
g.append("g").attr("transform", `translate(0, 0)`)
.call(d3.axisRight()
.scale(yscale).ticks(6)
.tickSize(graph_size)
.tickFormat(""))
.call(g => g.selectAll(".tick line")
.attr("stroke", "white")
.attr("stroke-opacity", 0.2));

// await until the image is decoded (it is too bad we have to encode
// it as PNG in the first place). This avoids flicker when changing settings.
if (pat_img.node().decode)
await pat_img.node().decode();
return chart;
}
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
gamut_canvas_symbol = Symbol('canvas')
Insert cell
gamut_url_symbol = Symbol('url')
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 = 0.70,
pre_perceptual_desat = 0,
norm_method = "RGB 4-norm",
clip_method = "Mix grey",
perceptual_hue_correct = false } = settings;
const resolution = canvas_width;
const canvas = DOM.canvas(resolution, resolution);
const ctx = canvas.getContext("2d", {colorSpace: "srgb"});
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]];

if (pre_perceptual_desat > 0)
{
rgb1 = OklabSaturate(rgb1, 1 - pre_perceptual_desat);
}
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 * 0.72 * max_reflectance_lut[data_offset]);
break;
}
case "Power":
{
rgb = normalize_lum(rgb1, rgb_norm * 0.50 * max_power_lut[data_offset]);
break;
}
}

rgb = rgb.map(x => x + bg_fill);
rgb = clip_method == "Mix grey"
? rgbGreyDesaturate(rgb, {grey: 0})
: rgb.map(d => Math.max(0, d));

if (perceptual_hue_correct) {
rgb = OklabCopyHue(rgb, rgb1);
}

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;
}
}

var div = html`<div>`;
ctx.putImageData(img_data, 0, 0);
div.appendChild(ctx.canvas);
ctx.canvas.style.width = resolution + 'px';
ctx.canvas.style.height = resolution + 'px';
div[gamut_canvas_symbol] = ctx.canvas;
div[gamut_url_symbol] = ctx.canvas.toDataURL('image/png');
return div;
}
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;
// 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 = XYZ_to_rgb(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
Type JavaScript, then Shift-Enter. Ctrl-space for more options. Arrow ↑/↓ to switch modes.

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 XYZ_to_rgb([x, y, z]) {
return [
3.2404542 * x - 1.5371385 * y - 0.4985314 * z,
-0.9692660 * x + 1.8760108 * y + 0.0415560 * z,
0.0556434 * x - 0.2040259 * y + 1.0572252 * z];
}
Insert cell
function rgb_to_XYZ([r, g, b])
{
return [
0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
0.2126729 * r + 0.7151522 * g + 0.0721750 * b,
0.0193339 * r + 0.1191920 * g + 0.9503041 * b];
}
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)
{
var scale = norm / (0.2126729 * r + 0.7151522 * g + 0.0721750 * b);
return [r * scale, g * scale, b * scale];
}
Insert cell
uv_data = {
var data = [];
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] = XYZ_to_rgb(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
import {cie1931, rgbGreyDesaturate, OklabSaturate, OklabCopyHue, colorToCss, sRGB} from "@roelandschoukens/rendering-a-spectrum"
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