Skip to content

Instantly share code, notes, and snippets.

@connorgr
Last active January 6, 2019 18:54
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save connorgr/84aaa3a86b7c1231be5221d26591a906 to your computer and use it in GitHub Desktop.

This interactive demo shows how changing the size and percent arguments affects just noticeable difference intervals for CIELAB L* (lightness), a* (redness-to-greenness), and b* (blueness-to-yellowness) color channels. It builds on the d3-jnd library, which extends D3 and was developed by Connor Gramazio using empirical work by Maureen Stone, Daniel Albers Szafir, and Vidya Setlur.

// https://github.com/connorgr/d3-jnd Version 0.1.0. Copyright 2016 Connor Gramazio.
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-color')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-color'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3));
}(this, (function (exports,d3Color) { 'use strict';
// Implementation based on Maureen Stone, Danielle Albers Szafir, and Vidya
// Setlur's paper "An Engineering Model for Color Difference as a Function of
// Size" presented at the Color Imaging Conference, and can be found online at
// https://research.tableau.com/sites/default/files/2014CIC_48_Stone_v3.pdf
//
// Their paper examines target sizes (visual angle) ranging from 6 to 1/3
// degree, so note that extrapolations outside that range contain additional
// untesteed assumptions about color appearence.
// To calculate whether colors are noticeably different, colors are translated
// into CIELAB perceptual color space. Further, users must specifiy a visual
// angle for how large the colored elements are (e.g., bars in a bar chart)
// along their smallest dimension (e.g., width for 25px wide x 100px tall bars).
// Variable definitions:
// nd: noticeable difference
// p: a threshold defined as the percentage of observers who see two colors
// separated by a particular color space interval (e.g., along L*) as
// different.
// s: size, specified in degrees of visual angle
//----------------------------------------.
// PREDICTING DISCRIMINABILITY THRESHOLDS \___________________________________
//=============================================================================|
// // p = V(s)*Delta_D + e (i.e., y=ax+b), where
// s: size,
// V(s) and D: vector values of L*, a*, b*
// e: error term
// Delta_D: a step in CIELAB space
// V(s): a vector of three slopes, which differ along L*, a*, and b*
//
// Therefore, Delta_D = nd(p) = p / V(s)
//
// For calculating just noticeable differences (JND), we'll assume that p should
// be fixed at 50%, which then leaves size as the only free variable for
// calculating discriminability intervals along L*, a*, and b* color channels.
//
// ND(50, s) = C(50) + K(50)/s, where C and K are regression coefficients
//
// Stone et al. also provide a generalized formula that can support p and s both
// as free variables based on additional regressions (see paper):
//
// ND(p,s) = p(A+B/s), where
// s: size,
// p: % of observers who see colors as different ([0,1])
// A and B: preset values that differ for each channel
//
function nd(p,s) {
var A = {l: 10.16, a: 10.68, b: 10.70},
B = {l: 1.50, a: 3.08, b: 5.74};
return {
l: p * (A.l + B.l / s),
a: p * (A.a + B.a / s),
b: p * (A.b + B.b / s)
};
}
function jndLabInterval(p, s) {
if(typeof s === "string") {
if(s === "thin") s = 0.1;
else if(s === "medium") s = 0.5;
else if(s === "wide") s = 1.0;
else s = 0.1;
}
if(typeof p === "string") {
if(s === "conservative") p = 0.8;
else p = 0.5;
}
return nd(p, s);
}
function noticeablyDifferent(c1, c2, s, p) {
if(arguments.length < 3) s = 0.1;
if(arguments.length < 4) p = 0.5;
var jnd = jndLabInterval(p, s);
c1 = d3Color.lab(c1);
c2 = d3Color.lab(c2);
return c1.l-c2.l >= jnd.l || c1.a-c2.a >= jnd.a || c1.b-c2.b >= jnd.b;
}
exports.jndLabInterval = jndLabInterval;
exports.noticeablyDifferent = noticeablyDifferent;
Object.defineProperty(exports, '__esModule', { value: true });
})));
function drawJndControls(container) {
var svgW = 150, svgH = 150;
var container = d3.select(container),
// containers for JND argument visualizations/controls
swatchContainer = container.append('div'),
percentContainer = container.append('div'),
sizeContainer = container.append('div');
swatchContainer.style('width', svgW*3 + 'px');
percentContainer.style('width', svgW*3 + 'px')
.style('border-top', '1px solid #ccc')
.style('padding-top', '5px');
sizeContainer.style('width', svgW*3 + 'px')
.style('border-bottom', '1px solid #ccc')
.style('border-top', '1px solid #ccc')
.style('padding-top', '5px');
var arg_percent = 0.5,
arg_size = 0.1;
var draggerYLab = d3.jndLabInterval(arg_percent, arg_size);
var baseColor = d3.lab(50,0,-25);//d3.lab('steelblue');
function updateComparisonSwatches() {
function channelAdjust(channel) {
var c = d3.lab(baseColor);
c[channel] = c[channel] + draggerYLab[channel];
return c;
}
d3.selectAll('.baseColor').attr('fill', baseColor);
d3.selectAll('.lColor').attr('fill', channelAdjust('l'));
d3.selectAll('.aColor').attr('fill', channelAdjust('a'));
d3.selectAll('.bColor').attr('fill', channelAdjust('b'));
function cText(prime, c) {
return ["lab"+(prime ? "'" : "")+"("+Math.round(c.l),
Math.round(c.a), Math.round(c.b)+")"].join(",");
}
d3.selectAll('.baseColorText').text(cText(false, baseColor));
d3.selectAll('.lColorText').text(cText(false, channelAdjust('l')));
d3.selectAll('.aColorText').text(cText(false, channelAdjust('a')));
d3.selectAll('.bColorText').text(cText(false, channelAdjust('b')));
}
updateComparisonSwatches();
function updateTable() {
d3.select("#tabArgPercent").text(Math.round(arg_percent*100)/100);
d3.select("#tabArgSize").text(Math.round(arg_size*100)/100);
d3.select("#tabDeltaL").text(Math.round(draggerYLab.l*100)/100);
d3.select("#tabDeltaA").text(Math.round(draggerYLab.a*100)/100);
d3.select("#tabDeltaB").text(Math.round(draggerYLab.b*100)/100);
}
updateTable();
function makePercentData() {
return Array.apply(null, Array(11))
.map(function(d,i) {
return {x: i/10.0, y: d3.jndLabInterval(i/10.0, arg_size) };
});
}
function makeSizeData() {
return Array.apply(null, Array(20))
.map(function(d,i) {
return {x: (i+1)/10.0, y: d3.jndLabInterval(arg_percent, (i+1)/10.0)};
});
}
var data_percent = makePercentData(),
data_size = makeSizeData();
var facets = [
returnLineChart(percentContainer, data_percent, "percentage", "L* interval", "percent", "l"),
returnLineChart(percentContainer, data_percent, "percentage", "a* interval", "percent", "a"),
returnLineChart(percentContainer, data_percent, "percentage", "b* interval", "percent", "b"),
returnLineChart(sizeContainer, data_size, "size", 'L* interval', "size", "l"),
returnLineChart(sizeContainer, data_size, "size", "a* interval", "size", "a"),
returnLineChart(sizeContainer, data_size, "size", "b* interval", "size", "b")
];
var facetDispatcher = d3.dispatch("facetDrag")
.on("facetDrag.dragged", function() {
data_percent = makePercentData();
data_size = makeSizeData();
facets.forEach(function(facet) {
var y = facet.yScale,
dragger = facet.dragger,
draggerChannel = facet.colorChannelType,
draggerArgumentType = facet.jndArgumentType;
var xVal = facet.jndArgumentType === "size" ? arg_size : arg_percent,
data = facet.jndArgumentType === "size" ? data_size : data_percent;
facet.svg.select('.line').remove().append("path");
facet.svg.select('g').append("path")
.datum(data)
.attr("class", "line")
.attr("d", facet.line);
dragger.attr("cx", facet.xScale(xVal))
.attr("cy", facet.yScale(draggerYLab[draggerChannel]));
updateComparisonSwatches();
updateTable();
});
});
function returnLineChart(container, data, xLabel, yLabel, type, channel) {
var svg = container.append("svg")
.attr('width', svgW).attr('height', svgH),
margin = {top: 5, right: 10, bottom: 38, left: 25},
width = +svg.attr("width") - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"),
draggerG = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var x = d3.scaleLinear().rangeRound([0, width]),
y = d3.scaleLinear().rangeRound([height, 0]);
var line = d3.line()
.x(function(d) { return x(d.x); })
.y(function(d) { return y(d.y[channel]); });
x.domain([0, d3.max(data, function(d) { return d.x; })]);
y.domain([0,80]);
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).ticks(5))
.append("text")
.attr("x", (svgW-margin.left-margin.right)/2)
.attr("y", 25)
.attr("dy", "0.71em")
.style("text-anchor", "middle")
.text(xLabel);
g.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(y).ticks(5))
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", "0.71em")
.style("text-anchor", "end")
.text(yLabel);
g.append("path")
.datum(data)
.attr("class", "line")
.attr("d", line);
var dragger = draggerG.append("circle")
.attr("class", "dragger")
.attr("cx", x(type === "size" ? arg_size : arg_percent))
.attr("cy", y(draggerYLab[channel]))
.attr("r", 5)
.call(d3.drag().on("drag", dragged));
function dragged(d) {
var thisEl = d3.select(this),
eventX = d3.event.x > x.range()[1] ? x.range()[1] : d3.event.x;
if(type === "percent") {
eventX = eventX < 0 ? 0 : eventX;
arg_percent = x.invert(eventX);
} else {
eventX = x.invert(eventX) < 0.1 ? x(0.1) : eventX;
arg_size = x.invert(eventX);
}
draggerYLab = d3.jndLabInterval(arg_percent, arg_size);
facetDispatcher.call("facetDrag");
}
return {svg: svg, xScale: x, yScale: y, dragger: dragger, line: line,
jndArgumentType: type, colorChannelType: channel};
}
}
<!DOCTYPE html>
<meta charset="utf-8">
<style>
* {
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
}
text {
font-size: 12px;
}
.jndExample {
display: inline-block;
padding: 15px;
}
.swatchSVG {
border: 1px solid rgb(119, 119, 119);
height: 110px;
width: 340px;
}
.controlList {
float: left;
}
.controlList li {
margin: 0;
padding: 0;
list-style: none;
text-align: right;
}
.controlList li:not(:first-child) {
margin-top: 10px;
}
.axis line, .axis path {
stroke: #aaa;
}
.axis text {
fill: #666;
}
.line {
fill: none;
stroke: steelblue;
stroke-width: 1.5px;
}
.dragger {
fill: orange;
stroke: rgba(0,0,0,0.75);
stroke-width: 1.5;
cursor: -webkit-grab;
cursor: grab;
}
.labText {
font-size: 10px;
}
</style>
<div class="jndExample">
<table>
<thead>
<tr>
<td width="100">Arg: Percent</td>
<td width="70">Arg: Size</td>
<td width="50">∆L*</td>
<td width="50">∆a*</td>
<td width="50">∆b*</td>
</tr>
</thead>
<tbody>
<tr>
<td id="tabArgPercent"></td>
<td id="tabArgSize"></td>
<td id="tabDeltaL"></td>
<td id="tabDeltaA"></td>
<td id="tabDeltaB"></td>
</tr>
</tbody>
</table>
<svg class="swatchSVG">
<text x="5" y="15">L* comparison</text>
<text x="120" y="15">a* comparison</text>
<text x="235" y="15">b* comparison</text>
<text x="45" y="85" class="labText baseColorText"></text>
<text x="45" y="100" class="labText lColorText"></text>
<text x="160" y="85" class="labText baseColorText"></text>
<text x="160" y="100" class="labText aColorText"></text>
<text x="275" y="85" class="labText baseColorText"></text>
<text x="275" y="100" class="labText bColorText"></text>
<g class="bigSwatches" transform="translate(0, 15)">
<rect x="54" y="5" width="51" height="50" class="lColor"></rect>
<rect x="5" y="5" width="50" height="50" class="baseColor"></rect>
<rect x="169" y="5" width="51" height="50" class="aColor"></rect>
<rect x="120" y="5" width="50" height="50" class="baseColor"></rect>
<rect x="284" y="5" width="51" height="50" class="bColor"></rect>
<rect x="235" y="5" width="50" height="50" class="baseColor"></rect>
</g>
<g class="smallSwatches" transform="translate(0,75)">
<rect x="14" y="5" width="11" height="10" class="lColor"></rect>
<rect x="5" y="5" width="10" height="10" class="baseColor"></rect>
<rect x="129" y="5" width="11" height="10" class="aColor"></rect>
<rect x="120" y="5" width="10" height="10" class="baseColor"></rect>
<rect x="244" y="5" width="11" height="10" class="bColor"></rect>
<rect x="235" y="5" width="10" height="10" class="baseColor"></rect>
</g>
<g class="xsmallSwatches" transform="translate(0,95)">
<rect x="9" y="5" width="6" height="5" class="lColor"></rect>
<rect x="5" y="5" width="5" height="5" class="baseColor"></rect>
<rect x="124" y="5" width="6" height="5" class="aColor"></rect>
<rect x="120" y="5" width="5" height="5" class="baseColor"></rect>
<rect x="239" y="5" width="6" height="5" class="bColor"></rect>
<rect x="235" y="5" width="5" height="5" class="baseColor"></rect>
<rect x="29" y="5" width="6" height="1" class="lColor"></rect>
<rect x="25" y="5" width="5" height="1" class="baseColor"></rect>
<rect x="144" y="5" width="6" height="1" class="aColor"></rect>
<rect x="140" y="5" width="5" height="1" class="baseColor"></rect>
<rect x="259" y="5" width="6" height="1" class="bColor"></rect>
<rect x="255" y="5" width="5" height="1" class="baseColor"></rect>
</g>
</svg>
<div id="controls1"></div>
</div>
<script src="https://d3js.org/d3.v4.js"></script>
<script src="d3-jnd.js"></script>
<script src="drawJndControls.js"></script>
<script>
d3.selectAll('.swatch').style('width', '200px').style('height', '200px')
.style('background-color', 'steelblue');
drawJndControls('#controls1');
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment