WCAG 2.2 asks 3:1 contrast for meaningful graphics (criterion 1.4.11) and 4.5:1 for text (1.4.3), computed as (L1 + 0.05) / (L2 + 0.05) from relative luminances:
import { rgb } from 'd3';
const channel = c => (c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const luminance = hex => { const { r, g, b } = rgb(hex);
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); };
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + 0.05)
/ (Math.min(luminance(a), luminance(b)) + 0.05);
for (const hex of ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'])
console.log(hex, contrast(hex, '#ffffff').toFixed(2));Output
#1f5f8b 6.84 #5b3f99 8.03 #e09a10 2.39 #3f7d3a 4.99 #b5452f 5.44 #2a9d8f 3.32
Saffron fails at 2.39; labels rescue its bars, but thin lines need d3.color('#e09a10').darker(0.4) (#c2860e, 3.13). About one man in twelve has red-green color vision deficiency, so label directly rather than rely on hue, and prefer schemeTableau10 or interpolateCividis; DevTools' Rendering pane emulates it.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
svg { width: 100%; max-width: 600px; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p>Background: <select id="bg"><option value="#ffffff">white</option><option value="#fafaf7">page cream</option><option value="#263238">dark slate</option></select>
saffron darker(<input type="range" id="k" min="0" max="1" step="0.05" value="0.4"> <span id="kv">0.4</span>)</p>
<svg viewBox="0 0 600 250" font-size="12"></svg>
<script>
// BookNest sample data, invented for these examples (not real sales)
const SALES = (() => {
const books = [
{ id: 1, key: 'harbor', title: 'The Quiet Harbor', author: 'Elena Marsh', genre: 'Fiction', color: '#1f5f8b', price: 18.99, rating: 4.6 },
{ id: 2, key: 'deepweb', title: 'Patterns of the Deep Web', author: 'Ravi Menon', genre: 'Technology', color: '#5b3f99', price: 39.5, rating: 4.3 },
{ id: 3, key: 'saffron', title: 'Salt and Saffron', author: 'Leila Haddad', genre: 'Cooking', color: '#e09a10', price: 24, rating: 4.8 },
{ id: 4, key: 'summit', title: 'Small Steps to Big Summits', author: 'Tom Okafor', genre: 'Self-Help', color: '#3f7d3a', price: 14.99, rating: 4.1 },
{ id: 5, key: 'clock', title: "The Clockmaker's Paradox", author: 'Iris Vale', genre: 'Science Fiction', color: '#b5452f', price: 16.2, rating: 4.5 },
{ id: 6, key: 'glass', title: 'Gardens in Glass', author: 'June Park', genre: 'Gardening', color: '#2a9d8f', price: 22.5, rating: 4.4 },
];
const units = { // units sold per month, January 2025 to August 2026
1: [40, 47, 46, 47, 57, 51, 46, 54, 60, 60, 71, 111, 52, 42, 49, 56, 59, 48, 53, 51],
2: [40, 37, 35, 41, 48, 43, 40, 42, 50, 53, 53, 84, 42, 35, 38, 41, 45, 41, 46, 46],
3: [46, 45, 41, 46, 53, 49, 46, 45, 60, 51, 63, 100, 48, 48, 49, 48, 53, 49, 48, 52],
4: [100, 80, 72, 70, 66, 66], // released March 2026
5: [48, 47, 54, 52, 65, 55, 57, 58, 64, 66, 75, 124, 58, 49, 59, 56, 63, 57, 54, 57],
6: [31, 30, 28, 35, 31, 32, 29, 30, 41, 36, 45, 64, 31, 30, 30, 38, 40, 38, 36, 38],
};
const months = d3.utcMonths(new Date('2025-01-01'), new Date('2026-09-01')).map(d3.utcFormat('%Y-%m'));
const sales = books.flatMap(b => units[b.id].map((n, i) =>
({ month: months[i + months.length - units[b.id].length], book: b.id, units: n })));
const ratings = [[4, 8, 20, 87, 293], [6, 10, 22, 95, 147], [2, 3, 8, 45, 342], [2, 3, 8, 24, 28],
[4, 8, 20, 90, 228], [4, 7, 20, 82, 147]].map((counts, i) => ({ book: i + 1, counts })); // 1 to 5 stars
const regions = [{ id: '826', name: 'United Kingdom', units: 1480 }, { id: '276', name: 'Germany', units: 1120 },
{ id: '250', name: 'France', units: 760 }, { id: '372', name: 'Ireland', units: 540 },
{ id: '528', name: 'Netherlands', units: 410 }, { id: '724', name: 'Spain', units: 320 }];
const links = [[5, 1, 38], [1, 3, 24], [2, 5, 18], [3, 6, 21], [4, 1, 12], [2, 6, 9], [4, 3, 15], [5, 6, 14]]
.map(([source, target, value]) => ({ source, target, value })); // customers who bought both
return { books, sales, ratings, regions, links };
})();
// WCAG 2.2: (L1 + 0.05) / (L2 + 0.05) from relative luminances
const channel = c => (c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const luminance = hex => { const { r, g, b } = d3.rgb(hex); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); };
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + 0.05) / (Math.min(luminance(a), luminance(b)) + 0.05);
const x = d3.scaleLinear([1, 8], [170, 590]), svg = d3.select('svg');
const bg = svg.append('rect').attr('width', 600).attr('height', 250);
function draw() {
const back = d3.select('#bg').property('value'), k = +d3.select('#k').property('value');
d3.select('#kv').text(k);
const fg = back === '#263238' ? '#fafaf7' : '#263238';
bg.attr('fill', back);
const rows = SALES.books.map(b => ({ name: b.key, color: b.color }))
.concat([{ name: `saffron darker(${k})`, color: d3.color('#e09a10').darker(k).formatHex() }])
.map(r => ({ ...r, ratio: contrast(r.color, back) }));
const g = svg.selectAll('g.row').data(rows, r => r.name.split(' ')[0] + (r.name.includes('darker') ? '*' : ''))
.join(enter => { const g = enter.append('g').attr('class', 'row'); g.append('rect'); g.append('text').attr('class', 'n');
g.append('text').attr('class', 'v'); return g; })
.attr('transform', (r, i) => `translate(0, ${12 + i * 30})`);
g.select('rect').attr('x', x(1)).attr('height', 22).attr('fill', r => r.color).attr('width', r => x(r.ratio) - x(1));
g.select('.n').attr('x', 160).attr('y', 15).attr('text-anchor', 'end').attr('fill', fg).text(r => `${r.name} ${r.color}`);
g.select('.v').attr('x', r => x(r.ratio) + 5).attr('y', 15).attr('fill', r => r.ratio >= 3 ? fg : '#b5452f')
.text(r => `${r.ratio.toFixed(2)}:1 ${r.ratio >= 4.5 ? '✓ text' : r.ratio >= 3 ? '✓ graphics' : '✗ fails 3:1'}`);
svg.selectAll('line.req').data([3, 4.5]).join('line').attr('class', 'req').attr('x1', x).attr('x2', x)
.attr('y1', 6).attr('y2', 226).attr('stroke', fg).attr('stroke-dasharray', '4 3');
svg.selectAll('text.req').data([[3, 'graphics 3:1 (1.4.11)'], [4.5, 'text 4.5:1 (1.4.3)']]).join('text').attr('class', 'req')
.attr('x', ([v]) => x(v) + 3).attr('y', 242).attr('fill', fg).text(([, t]) => t);
}
d3.selectAll('select, input').on('input', draw);
draw();
</script>