HomeJournalThis post

Bivariate Color Scales Without Mixed Signals

Build and audit a labeled 3×3 bivariate palette, legend, and non-color fallback so two ordered variables remain independently decodable.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Bivariate color scales encode two ordered variables in one compact field, but a pretty nine-cell blend is useless if readers cannot recover both values. This tutorial builds a labeled 3×3 palette, audits order and separation, and keeps pattern, text, or small multiples ready when color cannot carry the decision.

Decide whether the variables belong together

Bivariate color scales are useful only when the reader must compare two ordered variables for the same marks. The technique can expose combinations such as high need with low access, where either single-variable map would hide the decision. It is a poor fit when the variables have no meaningful joint question, when one is nominal, or when nine combinations overwhelm the available space.

Write the lookup task before choosing colors: “Find regions with high exposure and low readiness,” not “show both datasets.” The CDC tutorial on bivariate mapping for resource allocation demonstrates the value of intersecting two area-level measures for action, while the cartographic guide to a bivariate choropleth palette makes construction choices visible. The lesson travels beyond maps: a matrix of color combinations needs a decision that genuinely depends on both axes. Bivariate color scales should expose that decision in their caption.

Check direction and semantics. If high is good on one axis and bad on the other, labels must say so; a diagonal that looks like a single severity ramp will mislead. If the variables are strongly redundant, one scale plus annotation may be clearer. If readers mostly compare exact values, a table or scatterplot is likely better. Start with this editorial test because no palette can rescue a joint encoding that lacks a joint question.

Classify both ordered axes explicitly

Each axis needs a stable classification policy. Three bins are often enough for a compact bivariate choropleth palette: low, medium, and high for x; low, medium, and high for y. Choose thresholds from domain rules when those rules carry meaning. Quantiles can balance counts, but they describe relative rank inside this dataset and may move when new records arrive. Equal intervals are easy to explain but can leave bins empty in skewed data.

The lab accepts declared cuts or computes deterministic quantiles from finite numeric values. It records the effective boundaries, inclusion rule, and count in each cell. Values on a cut use one documented upper-bin convention. Missing values remain missing; they never fall into a low bucket. Labels are bounded plain text and appear in both the legend and output receipt. Bivariate color scales become comparable across editions only when this classification record travels with them.

Version the cuts with the dataset edition. A map refreshed monthly should not silently recolor the same county because a national quantile shifted. When comparison through time matters, freeze meaningful thresholds or display the change explicitly. Uncertainty-aware accessible charts provide complementary patterns when classification precision is itself uncertain. The essential move is to make the binning rule reviewable before anyone evaluates the color.

Construct bivariate color scales as a matrix

Build two perceptual ramps that meet at a light neutral corner, then blend their coordinates in a declared color space. The artifact uses bounded OKLab endpoints and a simple coordinate blend to produce nine cells. This is a reproducible construction recipe, not a claim that mathematical interpolation automatically creates a good palette. Every cell is converted to sRGB, clipped when necessary, and labeled by both axis categories.

Treat the three-by-three grid as one system. Adjacent steps along x should preserve x order at every y level; adjacent steps along y should preserve y order at every x level. The two extreme axes must remain distinguishable, and the mixed high-high corner needs a semantic role rather than merely becoming “darkest.” OKLCH color foundations explain the perceptual coordinate system, while the Delta E design-system audit shows why measured distance supports but does not replace judgment. Bivariate color scales need both measured separation and authored semantics.

Allow carefully validated cell overrides for art direction, but audit the result after every override. A hand-tuned center can improve harmony and still collapse into a neighbor. The output therefore records source coordinates, final sRGB colors, clipping, and pairwise separations so a designer can distinguish authored intent from generated arithmetic.

Labeled three by three bivariate paletteNine numbered color cells sit between a low-to-high horizontal need axis and a low-to-high vertical access axis, with every cell named by both categories.0,01,02,00,11,12,10,21,22,2NEED · LOW → HIGHACCESS · LOW → HIGHEvery cell = both labels
A bivariate color scale remains an ordered matrix; the row and column labels are part of the encoding, not decoration.
Nine bivariate legend cells
Access NeedLowMediumHigh
High0,2 low need / high access1,2 medium need / high access2,2 high need / high access
Medium0,1 low need / medium access1,1 medium / medium2,1 high need / medium access
Low0,0 low / low1,0 medium need / low access2,0 high need / low access

Preserve order and separation

A palette succeeds when the reader can move along either axis without losing the ordinal story. The lab checks lightness progression in each row and column, calculates OKLab distance for horizontal and vertical neighbors, and reports the smallest separation. It also computes text contrast against black and white so a consuming interface can choose a legible label treatment. These are diagnostics, not universal pass thresholds for every map size or display.

Grayscale is a deliberate stress test. Nine cells rarely remain uniquely decodable without color, and the CDC article notes that three-by-three bivariate maps rely on color more than simpler two-by-two maps. The receipt groups cells with near-equal grayscale luminance instead of pretending the palette passes. That result can trigger patterns, direct labels, or small multiples.

Color-distance numbers need names and context. A Delta E value from OKLab is not interchangeable with an APCA or WCAG contrast ratio. APCA versus WCAG contrast helps separate text readability questions from palette distinctness. Report the metric, implementation, and comparison pair. The purpose is to catch collapses before publication and create a shared review surface, not to turn one threshold into aesthetic permission.

Make the two-variable color legend a decoder

The legend is the interface for the encoding. Put the x label below a left-to-right arrow and the y label beside a bottom-to-top arrow. Label both endpoints with words, not only numbers. Keep the cell layout identical to the map's bin logic. A reader should be able to point at one mark, locate its legend cell, and say both category names without mentally rotating the grid. This is the minimum contract for a two-variable color legend.

Use a short numbered path when teaching the chart: first locate the horizontal category, then the vertical category, then confirm the intersection. The second thesis figure makes that path visible for one middle-high cell and repeats the exact values in an ordered list. This small explanation often matters more than another round of palette tuning because unfamiliar bivariate encodings impose a learned decoding task.

Place the legend near the plot, not in a distant appendix, and preserve it at mobile widths. Do not shrink nine labels below comfortable reading size. If the available card cannot hold the matrix, offer a focused label for the selected mark or switch to small multiples. Bivariate color scales should reduce comparison work for a specific question; a legend that requires memorization reverses that benefit. A multivariate map design earns its density only when decoding stays local and repeatable.

Decode one cell in three numbered movesA highlighted mark follows a numbered path from the horizontal high-need bin to the vertical medium-access bin and then to their amber-green intersection.1high need2medium access3intersection
The legend teaches a repeatable lookup: horizontal category, vertical category, then their named intersection.
  1. Read the mark's x value and locate high need.
  2. Read the y value and locate medium access.
  3. Confirm cell 2,1: high need / medium access.

Add labels and pattern redundancy

Color cannot carry the only copy of meaning. WCAG's guidance on use of color requires information in addition to color, such as text or shape. For a map, the practical options include direct region labels, a details table keyed by region, patterns for a critical subset, and interaction that announces both variables in an accessible name. Bivariate color scales still need this redundancy when their measured separations pass.

The lab emits self-contained SVG and HTML with the two axis labels, a text label for every legend cell, and optional hatch identifiers. Pattern is not free: nine dense textures can become harder to distinguish than nine colors. Reserve redundant marks for the decision states readers must find, or use a simple family along one axis while color expresses the other. Always keep the textual data table as the complete fallback.

Test forced colors, zoom, print, and grayscale. A map can retain beautiful hues while its legend borders vanish or its selected outline becomes indistinguishable. The failure gallery visual pairs each collapse with a recovery: adjacent colors merge, so add a pattern; labels crowd, so show one variable per panel; exact lookup is slow, so expose a table. Accessible data visualization is a system of equivalent paths, not a magic palette.

Failure gallery and redundant recoveryThree panels show adjacent colors collapsing, grayscale ambiguity, and cramped labels, followed by pattern, text, and small-multiple recoveries.COLLAPSEadd pattern + labelsGRAYSCALEkeep text tableCROWDINGuse small multiples
When color stops separating both variables, the recovery changes the representation instead of lowering the audit bar.
Adjacent cells merge
Add restrained patterns and direct category labels.
Grayscale groups collide
Keep the complete text table and emphasize task-critical states.
Nine labels crowd
Split one variable into consistently scaled small multiples.

Use small multiples when the blend stops helping

Small multiples trade compactness for separability. Show one map for low, medium, and high x, then encode y with a single ordered ramp inside each panel. The reader scans more space but never has to infer a mixed color. This is often the safer fallback for presentations, monochrome printing, narrow cards, or audiences encountering the data only once.

Keep geography, scale, cuts, and y ramp identical across panels. Highlight the same focus regions and repeat the x category in each title. A pattern fallback may work for an interactive map where one or two cells matter; small multiples work better when every cell must remain available for comparison. The artifact recommends a fallback when neighbor separation, grayscale grouping, or label constraints cross declared audit thresholds. Bivariate color scales should yield to panels before the decoding task breaks.

Do not frame fallback as failure. The bivariate view can remain the overview while a synchronized pair of univariate views answers precise questions. A median-cut versus k-means palette analysis is relevant when deriving colors from imagery, but data encodings should not inherit an image's clusters without preserving order and semantics. Choose the representation that keeps the reader's task intact, even when that means using less visual cleverness.

Audit bivariate color scales with real tasks

Run the lab with the real values, labels, cuts, ramps, and any cell overrides. Save the JSON receipt and generated legend beside the chart. Then recruit a few representative readers and ask concrete questions: find high x with low y, compare two named regions, explain the high-high cell, and identify a missing value. Record accuracy and hesitation rather than asking whether the colors look good.

The automated receipt covers deterministic bins, color conversion, order, neighbor distance, text contrast choices, grayscale groups, clipping, and fallback recommendations. Independent tests recalculate OKLab and contrast values, reject unsafe markup, exercise custom fixtures, and kill a mutant that transposes the axes. Those checks protect the contract; they cannot measure comprehension, display conditions, or cultural associations.

Bivariate color scales work when palette, legend, labels, data table, and fallback tell the same two-axis story. Publish the class boundaries and a downloadable table. Revisit the encoding when the data distribution, task, or viewing context changes. The honest outcome may be a strong nine-cell palette, a patterned emphasis layer, or small multiples. What matters is that two variables remain independently decodable and the chart never asks color alone to carry the decision.

Runnable artifact — Deterministic binning and declared OKLab diagnostics for this fixture; not proof of human comprehension, display consistency, or universal accessibility.

JavaScript16 lines
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const MAX_BYTES=262144,VERSION="bivariate-scale-lab-v1";
const builtIn={xLabel:"Need",yLabel:"Access",xLow:"low need",xHigh:"high need",yLow:"low access",yHigh:"high access",data:[{id:"North",x:18,y:72},{id:"East",x:42,y:36},{id:"South",x:81,y:20},{id:"West",x:67,y:64},{id:"Center",x:50,y:50},{id:"Harbor",x:90,y:88}],xCuts:[40,70],yCuts:[40,70],xRamp:["#f5f1e8","#d6c07a","#a86c21"],yRamp:["#f5f1e8","#75c6c8","#087f8c"],overrides:{},minimumNeighborDistance:.045,minimumTextContrast:4.5};
const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==="object"?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value,canonical=value=>JSON.stringify(normalize(value)),hash=value=>createHash("sha256").update(typeof value==="string"?value:canonical(value)).digest("hex");
function parse(){const args=process.argv.slice(2);if(args.length===0)return structuredClone(builtIn);if(args.length!==2||args[0]!=="--fixture")throw new Error("invalid-arguments");const bytes=readFileSync(args[1]);if(bytes.length>MAX_BYTES)throw new Error("fixture-too-large");try{return JSON.parse(bytes.toString("utf8"));}catch{throw new Error("malformed-fixture");}}
const safe=value=>typeof value==="string"&&/^[A-Za-z0-9][A-Za-z0-9 .,_()/%+-]{0,79}$/.test(value),hex=value=>typeof value==="string"&&/^#[0-9a-fA-F]{6}$/.test(value);
function validate(f){if(!f||typeof f!=="object"||Array.isArray(f))throw new Error("invalid-fixture");const allowed=new Set(["xLabel","yLabel","xLow","xHigh","yLow","yHigh","data","xCuts","yCuts","xRamp","yRamp","overrides","minimumNeighborDistance","minimumTextContrast"]);if(Object.keys(f).some(k=>!allowed.has(k)))throw new Error("unknown-field");for(const key of ["xLabel","yLabel","xLow","xHigh","yLow","yHigh"])if(!safe(f[key]))throw new Error("unsafe-label");if(!Array.isArray(f.data)||f.data.length<3||f.data.length>2048)throw new Error("invalid-data");const ids=new Set();for(const row of f.data){if(!row||Object.keys(row).some(k=>!["id","x","y"].includes(k))||!safe(row.id)||ids.has(row.id)||!Number.isFinite(row.x)||!Number.isFinite(row.y))throw new Error("invalid-row");ids.add(row.id);}for(const key of ["xRamp","yRamp"])if(!Array.isArray(f[key])||f[key].length!==3||f[key].some(v=>!hex(v)))throw new Error("invalid-ramp");for(const key of ["xCuts","yCuts"])if(f[key]!=null&&(!Array.isArray(f[key])||f[key].length!==2||f[key].some(v=>!Number.isFinite(v))||f[key][0]>=f[key][1]))throw new Error("invalid-cuts");if(!f.overrides||typeof f.overrides!=="object"||Array.isArray(f.overrides)||Object.entries(f.overrides).some(([k,v])=>!/^[0-2],[0-2]$/.test(k)||!hex(v)))throw new Error("invalid-overrides");if(!Number.isFinite(f.minimumNeighborDistance)||f.minimumNeighborDistance<0||f.minimumNeighborDistance>1||!Number.isFinite(f.minimumTextContrast)||f.minimumTextContrast<1||f.minimumTextContrast>21)throw new Error("invalid-threshold");return f;}
const quantile=(values,p)=>{const a=[...values].sort((x,y)=>x-y),position=(a.length-1)*p,lo=Math.floor(position),hi=Math.ceil(position);return a[lo]+(a[hi]-a[lo])*(position-lo);},cuts=(f,key)=>f[key]??[quantile(f.data.map(r=>r[key[0]]),1/3),quantile(f.data.map(r=>r[key[0]]),2/3)],bin=(v,c)=>v<c[0]?0:v<c[1]?1:2;
const srgbToLinear=x=>{x/=255;return x<=.04045?x/12.92:Math.pow((x+.055)/1.055,2.4);},linearToSrgb=x=>255*(x<=.0031308?12.92*x:1.055*Math.pow(Math.max(0,x),1/2.4)-.055),rgb=hex=>[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)),toHex=a=>"#"+a.map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,"0")).join("");
function rgbToOklab(color){const [r,g,b]=rgb(color).map(srgbToLinear),l=.4122214708*r+.5363325363*g+.0514459929*b,m=.2119034982*r+.6806995451*g+.1073969566*b,s=.0883024619*r+.2817188376*g+.6299787005*b,L=Math.cbrt(l),M=Math.cbrt(m),S=Math.cbrt(s);return[.2104542553*L+.793617785*M-.0040720468*S,1.9779984951*L-2.428592205*M+.4505937099*S,.0259040371*L+.7827717662*M-.808675766*S];}
function oklabToRgb([L,a,b]){const l=Math.pow(L+.3963377774*a+.2158037573*b,3),m=Math.pow(L-.1055613458*a-.0638541728*b,3),s=Math.pow(L-.0894841775*a-1.291485548*b,3);return[linearToSrgb(4.0767416621*l-3.3077115913*m+.2309699292*s),linearToSrgb(-1.2684380046*l+2.6097574011*m-.3413193965*s),linearToSrgb(-.0041960863*l-.7034186147*m+1.707614701*s)];}
const distance=(a,b)=>Math.hypot(...a.map((v,i)=>v-b[i])),luminance=color=>{const [r,g,b]=rgb(color).map(srgbToLinear);return .2126*r+.7152*g+.0722*b;},contrast=(a,b)=>{const x=luminance(a),y=luminance(b);return(Math.max(x,y)+.05)/(Math.min(x,y)+.05);};
function build(f){const xCuts=cuts(f,"xCuts"),yCuts=cuts(f,"yCuts");if(!(xCuts[0]<xCuts[1])||!(yCuts[0]<yCuts[1]))throw new Error("degenerate-quantile-cuts");const x=f.xRamp.map(rgbToOklab),y=f.yRamp.map(rgbToOklab),base=rgbToOklab(f.xRamp[0]),cells=[];for(let yi=0;yi<3;yi++)for(let xi=0;xi<3;xi++){const key=xi+","+yi,lab=[x[xi][0]+y[yi][0]-base[0],x[xi][1]+y[yi][1]-base[1],x[xi][2]+y[yi][2]-base[2]],raw=oklabToRgb(lab),clipped=raw.some(v=>v<0||v>255),color=f.overrides[key]??toHex(raw),finalLab=rgbToOklab(color),black=contrast(color,"#000000"),white=contrast(color,"#ffffff");cells.push({key,xBin:xi,yBin:yi,color,oklab:finalLab.map(v=>Number(v.toFixed(6))),clipped,textColor:black>=white?"#000000":"#ffffff",textContrast:Number(Math.max(black,white).toFixed(3))});}const assignments=f.data.map(row=>{const xBin=bin(row.x,xCuts),yBin=bin(row.y,yCuts);return{id:row.id,x:row.x,y:row.y,xBin,yBin,cell:xBin+","+yBin,color:cells.find(c=>c.key===xBin+","+yBin).color};}),neighbors=[];for(const cell of cells)for(const other of cells)if((Math.abs(cell.xBin-other.xBin)+Math.abs(cell.yBin-other.yBin))===1&&cell.key<other.key)neighbors.push({pair:[cell.key,other.key],distance:Number(distance(cell.oklab,other.oklab).toFixed(6))});const grayscale={};for(const cell of cells){const group=(Math.round(luminance(cell.color)*10)/10).toFixed(1);(grayscale[group]??=[]).push(cell.key);}const collisions=Object.values(grayscale).filter(group=>group.length>1),minimum=Math.min(...neighbors.map(n=>n.distance)),orderX=[0,1,2].every(yi=>cells.filter(c=>c.yBin===yi).sort((a,b)=>a.xBin-b.xBin).every((c,i,a)=>i===0||c.oklab[0]<=a[i-1].oklab[0]+.08)),orderY=[0,1,2].every(xi=>cells.filter(c=>c.xBin===xi).sort((a,b)=>a.yBin-b.yBin).every((c,i,a)=>i===0||c.oklab[0]<=a[i-1].oklab[0]+.08)),textContrastFailures=cells.filter(c=>c.textContrast<f.minimumTextContrast).map(c=>c.key),minimumTextContrastPass=textContrastFailures.length===0,fallbackReasons=[...(minimum<f.minimumNeighborDistance?["neighbor-separation"]:[]),...(collisions.length?["grayscale-collision"]:[]),...(!minimumTextContrastPass?["text-contrast"]:[])];return{xCuts,yCuts,cutSource:{x:f.xCuts==null?"quantiles":"declared",y:f.yCuts==null?"quantiles":"declared"},cells,assignments,neighbors,minimumNeighborDistance:Number(minimum.toFixed(6)),minimumTextContrast:f.minimumTextContrast,minimumTextContrastPass,textContrastFailures,orderX,orderY,grayscaleCollisions:collisions,fallbackReasons,fallback:fallbackReasons.length?"labels-patterns-or-small-multiples":"labels-and-table"};}
const esc=s=>String(s).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");
const f=validate(parse()),result=build(f),rects=result.cells.map(c=>"<g><rect x=\""+(60+c.xBin*100)+"\" y=\""+(240-c.yBin*70)+"\" width=\"96\" height=\"66\" fill=\""+c.color+"\" stroke=\"currentColor\"/><text x=\""+(108+c.xBin*100)+"\" y=\""+(278-c.yBin*70)+"\" text-anchor=\"middle\" fill=\""+c.textColor+"\">"+c.key+"</text></g>").join(""),svg="<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-labelledby=\"title desc\" viewBox=\"0 0 420 330\"><title id=\"title\">"+esc(f.xLabel)+" by "+esc(f.yLabel)+" legend</title><desc id=\"desc\">Nine labeled cells. Horizontal: "+esc(f.xLow)+" to "+esc(f.xHigh)+". Vertical: "+esc(f.yLow)+" to "+esc(f.yHigh)+".</desc>"+rects+"<text x=\"210\" y=\"325\" text-anchor=\"middle\">"+esc(f.xLow)+" → "+esc(f.xHigh)+"</text><text transform=\"translate(18 190) rotate(-90)\" text-anchor=\"middle\">"+esc(f.yLow)+" → "+esc(f.yHigh)+"</text></svg>",table="<table><caption>"+esc(f.xLabel)+" and "+esc(f.yLabel)+" assignments</caption><thead><tr><th>Item</th><th>"+esc(f.xLabel)+" bin</th><th>"+esc(f.yLabel)+" bin</th><th>Cell</th></tr></thead><tbody>"+result.assignments.map(a=>"<tr><th>"+esc(a.id)+"</th><td>"+a.xBin+"</td><td>"+a.yBin+"</td><td>"+a.cell+"</td></tr>").join("")+"</tbody></table>",core={schema:VERSION,inputDigest:hash(f),...result,accessible:{svg,html:"<!doctype html><meta charset=\"utf-8\"><title>Bivariate legend</title><main>"+svg+table+"</main>"},claimBoundary:"Deterministic binning and declared OKLab diagnostics for this fixture; not proof of human comprehension, display consistency, or universal accessibility."},receipt={...core,receiptHash:hash(core),replayCommand:"node bivariate-scale-lab.mjs"+(process.argv.length>2?" --fixture <same-path>":"")};process.stdout.write(JSON.stringify(receipt,null,2)+"\n");