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.
Last active
January 6, 2019 18:54
Star
You must be signed in to star a gist
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 }); | |
}))); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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