How to: Build a U.S. Choropleth Map (D3)
This guide explains how to implement and adapt the USChoroplethD3 function for any client dataset, using D3 for geographic rendering and React for interactivity.
1. Overview
The choropleth map displays U.S. states shaded by a metric—such as Spend, CAC, FTUs, or Payback—with darker colors representing higher (or lower) values depending on the metric.
It automatically adjusts for screen size, includes interactive tooltips, and lets users filter by Platform and Funnel (all customizable)
Example Use Cases:
Media spend heatmaps by state
Customer acquisition cost (CAC) visualizations
Registrations or signups by funnel stage
Payback period analysis across markets
2. Required Setup
✅ Function Name
USChoroplethD3({ data = [], comparisonData = [] })
No JSX; all elements must use React.createElement.
✅ Libraries Needed
Ensure these are globally available in your environment:
React
d3
topojson
✅ Input Data Requirements
Each row in your dataset should include at least one field from each category below. The chart automatically recognizes the first matching field.
Data Type | Accepted Field Names | Example |
|---|---|---|
State |
|
|
Spend |
|
|
FTUs |
|
|
CAC (optional) |
|
|
Payback (optional) |
|
|
Platform |
|
|
Funnel |
|
|
💡 If a client uses custom field names (e.g.
geo_stateinstead ofstate), just modify the helper functions (getStateRaw,getSpend, etc.) to match.
3. Supported Metrics
The chart automatically computes or aggregates the following metrics (specific to fanatics, use vibe coding to update your metrics):
Metric | Type | Calculation | Color Logic |
|---|---|---|---|
Spend | Sum | Total media or ad spend | Higher = darker |
FTUs | Sum | Total first-time users | Higher = darker |
CAC | Average | Spend ÷ FTUs (if missing | Lower = darker |
Payback | Average | Mean payback period (months) | Lower = darker |
4. Color & Legend
Default color scale:
#E9F5EC→#7BC47F→#1E7F3A(light → dark green)Legend dynamically scales between your min/max values.
For Spend and FTUs, darker means higher.
For CAC and Payback, darker means lower (inverted). * client specific
You can easily replace the green palette to match any client brand colors.
5. Filters
The map auto-generates dropdowns for:
Metric
Platform
Funnel
These lists are built dynamically from your dataset values—no manual configuration needed.
To hide or modify a filter (e.g. remove “Funnel”), just remove that block in the “Controls” section.
6. Rendering & Responsiveness
The map uses a D3 Albers USA projection and fits to the container’s width.
Height is fixed at 520px for layout consistency.
It responds to width changes via
ResizeObserver, so it works well in resizable dashboards.
7. Tooltips
Each state shows:
State name
Metric name
Formatted value (
$,K/M/B, ormofor Payback)
Example:
Michigan
Spend: $2.3M
Tooltips appear on hover and follow the cursor.
8. Adapting for Multiple Clients
You can use the same component for any client by following these rules:
🔁 Field Mapping
If a client dataset uses different column names:
Edit only the helper functions at the top (
getStateRaw,getSpend, etc.)Do not change the rendering logic.
🎨 Branding
Update the color palette variables:
var light = '#E9F5EC', mid = '#7BC47F', dark = '#1E7F3A';
🧭 Metric Defaults
Set the initial metric to whatever makes sense:
var [metric, setMetric] = useState('Spend');
🧩 Add or Remove Metrics
To add a new metric (e.g. “Impressions” or “Revenue”):
Add the metric name to
METRICS.Extend the aggregation logic in
filteredAgg.Update
fmtVal()to format values correctly.
9. Troubleshooting
Issue | Likely Cause | Fix |
|---|---|---|
States missing or blank | State names not matching | Ensure two-letter postal codes or update |
CAC/Payback showing “—” | Missing or NaN values | Check data types; ensure numeric fields |
Legend reversed | Expected for CAC/Payback | Intended behavior |
Map doesn’t render | TopoJSON URL blocked | Host a local copy and update the fetch URL |
Spend totals too low | Filtering by platform/funnel | Confirm “All” selected or update dropdowns |
10. Validation Checklist
Before rolling out to a new client:
Dataset includes state + at least one metric field
Accessor functions point to correct field names
Metrics aggregate and display correctly
Color scale aligns with brand palette
Tooltip, legend, and filters all render
Spot-check totals vs. client data pivot
11. Optional Enhancements
Add a "Download Map as PNG" button using
d3-save-svgorhtml2canvas.Overlay comparison mode (use
comparisonDatainput).Replace dropdowns with external dashboard controls for global filters.
Fanatics Script Example:
function USChoroplethD3({ data = [], comparisonData = [] }) {
/*
CODE STRUCTURE GUIDELINES
- Do not use JSX; create everything with React.createElement.
- Assume all require statements are already included.
- Do not include any import statements.
- Use a function (not a class) for the chart.
- Function must accept { data = [], comparisonData = [] } as parameters.
- Use function keyword, not const, to define the chart function.
- For Highcharts: ensure styledMode is false. (Not applicable here.)
- Return a React element with React.createElement.
- Do not include date filtering in the code — handled separately.
- Return exactly one function and nothing else (no module.exports).
*/
var { useState, useEffect, useMemo, useRef } = React;
// -------------------- Helpers: data accessors --------------------
var METRICS = ['Spend', 'FTUs', 'CAC', 'Payback'];
function getStateRaw(r) { return r.state || r.region || r.us_state || r.state_name || r.state_code || r.geo || null; }
function getSpend(r) { return Number(r.media_spend != null ? r.media_spend : (r.total_spend != null ? r.total_spend : (r.cost != null ? r.cost : 0))) || 0; }
function getFTUs(r) { return Number(r.total_ftu != null ? r.total_ftu : (r.ftus != null ? r.ftus : (r.cda_new_reg != null ? r.cda_new_reg : 0))) || 0; }
function getCAC(r) {
var v = Number(r.cac != null ? r.cac : (r.cpa != null ? r.cpa : NaN));
if (isFinite(v)) return v;
var s = getSpend(r), f = getFTUs(r);
return f > 0 ? s / f : null;
}
function getPayback(r) {
var v = Number(r.payback_months != null ? r.payback_months : (r.payback != null ? r.payback : NaN));
return isFinite(v) ? v : null;
}
function getPlatform(r) {
return r.platform || r.publisher_platform || r.harmonized_platform || r.channel_platform || null;
}
function getFunnel(r) {
return r.funnel || r.harmonized_funnel || r.stage || null;
}
// Map full state names → postal code
var NAME_TO_CODE = {
'alabama':'AL','alaska':'AK','arizona':'AZ','arkansas':'AR','california':'CA','colorado':'CO','connecticut':'CT',
'delaware':'DE','district of columbia':'DC','florida':'FL','georgia':'GA','hawaii':'HI','idaho':'ID','illinois':'IL',
'indiana':'IN','iowa':'IA','kansas':'KS','kentucky':'KY','louisiana':'LA','maine':'ME','maryland':'MD','massachusetts':'MA',
'michigan':'MI','minnesota':'MN','mississippi':'MS','missouri':'MO','montana':'MT','nebraska':'NE','nevada':'NV',
'new hampshire':'NH','new jersey':'NJ','new mexico':'NM','new york':'NY','north carolina':'NC','north dakota':'ND',
'ohio':'OH','oklahoma':'OK','oregon':'OR','pennsylvania':'PA','rhode island':'RI','south carolina':'SC','south dakota':'SD',
'tennessee':'TN','texas':'TX','utah':'UT','vermont':'VT','virginia':'VA','washington':'WA','west virginia':'WV','wisconsin':'WI','wyoming':'WY'
};
// -------------------- UI state --------------------
var [metric, setMetric] = useState('Spend');
var [platform, setPlatform] = useState('All');
var [funnel, setFunnel] = useState('All');
var allPlatforms = useMemo(function () {
var set = {};
(Array.isArray(data) ? data : []).forEach(function (r) {
var p = getPlatform(r);
if (p != null && p !== '') set[String(p)] = true;
});
return Object.keys(set).sort();
}, [data]);
var allFunnels = useMemo(function () {
var set = {};
(Array.isArray(data) ? data : []).forEach(function (r) {
var f = getFunnel(r);
if (f != null && f !== '') set[String(f)] = true;
});
return Object.keys(set).sort();
}, [data]);
// -------------------- Aggregate by state code --------------------
var filteredAgg = useMemo(function () {
var acc = {};
var rows = Array.isArray(data) ? data : [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var platVal = getPlatform(r);
var funVal = getFunnel(r);
if (platform !== 'All' && String(platVal) !== platform) continue;
if (funnel !== 'All' && String(funVal) !== funnel) continue;
var s = getStateRaw(r);
if (!s) continue;
s = String(s).trim();
var code = null;
if (/^[A-Za-z]{2}$/.test(s)) code = s.toUpperCase();
else code = NAME_TO_CODE[s.toLowerCase()] || null;
if (!code) continue;
if (!acc[code]) acc[code] = { spend:0, ftus:0, cacSum:0, cacN:0, pbSum:0, pbN:0 };
var spend = getSpend(r);
var ftus = getFTUs(r);
var cac = getCAC(r);
var pb = getPayback(r);
acc[code].spend += spend;
acc[code].ftus += ftus;
if (isFinite(cac)) { acc[code].cacSum += cac; acc[code].cacN += 1; }
if (isFinite(pb)) { acc[code].pbSum += pb; acc[code].pbN += 1; }
}
Object.keys(acc).forEach(function (k) {
var o = acc[k];
o.cac = o.cacN > 0 ? o.cacSum / o.cacN : null;
o.payback = o.pbN > 0 ? o.pbSum / o.pbN : null;
});
return acc;
}, [data, platform, funnel]);
function valFor(code) {
var o = filteredAgg[code];
if (!o) return null;
if (metric === 'Spend') return o.spend;
if (metric === 'FTUs') return o.ftus;
if (metric === 'CAC') return o.cac;
if (metric === 'Payback') return o.payback;
return null;
}
// -------------------- Map data (TopoJSON) --------------------
// Use Highcharts' US TopoJSON because it includes postal codes in properties (works fine with D3).
var [topo, setTopo] = useState(null);
useEffect(function () {
if (topo) return;
var url = 'https://code.highcharts.com/mapdata/countries/us/us-all.topo.json';
fetch(url).then(function (r) { return r.json(); })
.then(function (t) { setTopo(t); })
.catch(function (e) { console.warn('Failed to load US map TopoJSON:', e); });
}, [topo]);
// -------------------- Container + rendering --------------------
var wrapRef = useRef(null);
var svgRef = useRef(null);
var tooltipRef = useRef(null);
// Create container children once
var [mounted, setMounted] = useState(false);
useEffect(function () {
if (mounted) return;
var wrap = wrapRef.current;
if (!wrap) return;
// Create SVG
var svg = d3.select(wrap).append('svg')
.attr('width', '100%')
.attr('height', 520)
.style('display', 'block')
.style('background', '#ffffff');
svgRef.current = svg.node();
// Tooltip (HTML)
var tip = d3.select(wrap).append('div')
.style('position', 'absolute')
.style('pointer-events', 'none')
.style('background', '#292A2EE5')
.style('color', '#fff')
.style('padding', '8px 10px')
.style('font', '12px Work Sans, system-ui, sans-serif')
.style('border-radius', '8px')
.style('line-height', '16px')
.style('opacity', 0);
tooltipRef.current = tip.node();
setMounted(true);
}, [mounted]);
// Responsive: track width
var [width, setWidth] = useState(900);
useEffect(function () {
var el = wrapRef.current;
if (!el) return;
function onResize() {
var w = el.getBoundingClientRect().width || 900;
setWidth(Math.max(320, Math.round(w)));
}
onResize();
var ro = new ResizeObserver(onResize);
ro.observe(el);
return function () { ro.disconnect(); };
}, []);
// Formatting helpers
function fmtMoney(n) {
if (!isFinite(n)) return '—';
var a = Math.abs(n);
if (a >= 1e9) return '$' + (n/1e9).toFixed(1) + 'B';
if (a >= 1e6) return '$' + (n/1e6).toFixed(1) + 'M';
if (a >= 1e3) return '$' + (n/1e3).toFixed(1) + 'K';
return '$' + n.toFixed(2);
}
function fmtNum(n) {
if (!isFinite(n)) return '—';
var a = Math.abs(n);
if (a >= 1e9) return (n/1e9).toFixed(1) + 'B';
if (a >= 1e6) return (n/1e6).toFixed(1) + 'M';
if (a >= 1e3) return (n/1e3).toFixed(1) + 'K';
return String(Math.round(n));
}
function fmtVal(v) {
if (!isFinite(v)) return '—';
if (metric === 'Spend' || metric === 'CAC') return fmtMoney(v);
if (metric === 'FTUs') return fmtNum(v);
if (metric === 'Payback') return v.toFixed(2) + ' mo';
return String(v);
}
// Draw / update map whenever inputs change
useEffect(function () {
if (!mounted || !svgRef.current || !tooltipRef.current || !topo) return;
// Prepare geometry and join key
var us = topojson.feature(topo, topo.objects.default || topo.objects['us-all']);
var features = (us && us.features) ? us.features : [];
// Property keys vary in this file; use robust fallbacks
function featureCode(f) {
var p = f && f.properties ? f.properties : {};
return (p['postal-code'] || p['hc-a2'] || p['code'] || '').toUpperCase();
}
function featureName(f) {
var p = f && f.properties ? f.properties : {};
return (p['name'] || p['hc-a2'] || p['postal-code'] || '').toString();
}
// Compute data values
var values = [];
var valueByCode = {};
for (var i = 0; i < features.length; i++) {
var code = featureCode(features[i]);
var v = valFor(code);
if (v != null && isFinite(v)) {
valueByCode[code] = v;
values.push(v);
}
}
var hasValues = values.length > 0;
var vMin = hasValues ? d3.min(values) : 0;
var vMax = hasValues ? d3.max(values) : 1;
var invert = (metric === 'CAC' || metric === 'Payback');
// Color scale (three-stop green)
var light = '#E9F5EC', mid = '#7BC47F', dark = '#1E7F3A';
var domain = invert ? [vMax, (vMin + vMax) / 2, vMin] : [vMin, (vMin + vMax) / 2, vMax];
var color = d3.scaleLinear().domain(domain).range([light, mid, dark]).unknown('#F3F4F6');
// Prepare SVG
var svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
var height = 520;
svg.attr('height', height);
// Title / subtitle
svg.append('text')
.attr('x', 16)
.attr('y', 24)
.attr('font-family', 'Work Sans, system-ui, sans-serif')
.attr('font-size', 16)
.attr('font-weight', 500)
.attr('fill', '#292A2E')
.text('US by ' + metric + (invert ? ' (lower = darker)' : ' (higher = darker)'));
// Projection + path, fit to width x (height - margins)
var margin = { top: 40, right: 16, bottom: 64, left: 16 };
var innerW = Math.max(320, width) - margin.left - margin.right;
var innerH = height - margin.top - margin.bottom;
var projection = d3.geoAlbersUsa().fitSize([innerW, innerH], us);
var path = d3.geoPath(projection);
var g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// Draw states
var tip = d3.select(tooltipRef.current);
g.selectAll('path.state')
.data(features)
.enter()
.append('path')
.attr('class', 'state')
.attr('d', path)
.attr('fill', function (d) {
var code = featureCode(d);
var v = valueByCode[code];
if (v == null || !isFinite(v)) return '#F3F4F6';
return color(v);
})
.attr('stroke', '#ffffff')
.attr('stroke-width', 0.8)
.on('mousemove', function (event, d) {
var code = featureCode(d);
var name = featureName(d);
var v = valueByCode[code];
tip.style('opacity', 1)
.style('left', (event.offsetX + 14) + 'px')
.style('top', (event.offsetY + 14) + 'px')
.html('<div style="font-weight:600;margin-bottom:2px">' + name + '</div>' +
'<div style="opacity:0.9">' + metric + ': ' + (v == null ? '—' : fmtVal(v)) + '</div>');
d3.select(this).attr('stroke', '#14532d').attr('stroke-width', 1.2);
})
.on('mouseleave', function () {
tip.style('opacity', 0);
d3.select(this).attr('stroke', '#ffffff').attr('stroke-width', 0.8);
});
// Legend (continuous)
var legendW = Math.min(260, Math.max(160, innerW * 0.4)), legendH = 10;
var legendX = margin.left;
var legendY = height - margin.bottom + 28;
// Gradient
var defs = svg.append('defs');
var gradId = 'grad-' + Math.random().toString(36).slice(2);
var grad = defs.append('linearGradient').attr('id', gradId);
grad.append('stop').attr('offset', '0%').attr('stop-color', invert ? dark : light);
grad.append('stop').attr('offset', '50%').attr('stop-color', mid);
grad.append('stop').attr('offset', '100%').attr('stop-color', invert ? light : dark);
svg.append('rect')
.attr('x', legendX)
.attr('y', legendY)
.attr('width', legendW)
.attr('height', legendH)
.attr('rx', 2)
.attr('fill', 'url(#' + gradId + ')');
// Legend axis labels
var leftVal = invert ? vMax : vMin;
var rightVal = invert ? vMin : vMax;
svg.append('text')
.attr('x', legendX)
.attr('y', legendY + legendH + 14)
.attr('font-family', 'Work Sans, system-ui, sans-serif')
.attr('font-size', 12)
.attr('fill', '#6B7280')
.text(hasValues ? (metric === 'FTUs' ? fmtNum(leftVal) : fmtVal(leftVal)) : '0');
svg.append('text')
.attr('x', legendX + legendW)
.attr('y', legendY + legendH + 14)
.attr('text-anchor', 'end')
.attr('font-family', 'Work Sans, system-ui, sans-serif')
.attr('font-size', 12)
.attr('fill', '#6B7280')
.text(hasValues ? (metric === 'FTUs' ? fmtNum(rightVal) : fmtVal(rightVal)) : '1');
// Footer note
svg.append('text')
.attr('x', legendX)
.attr('y', height - 10)
.attr('font-family', 'Work Sans, system-ui, sans-serif')
.attr('font-size', 11)
.attr('fill', '#8C8C8C')
.text('Higher = darker' + (invert ? ' (inverted for ' + metric + ')' : ''));
}, [mounted, topo, width, metric, platform, funnel, filteredAgg]);
// -------------------- Controls --------------------
var label = { fontSize: 12, color: '#6B7280' };
var sel = { fontFamily: 'Work Sans', fontSize: 12, padding: '6px 8px', borderRadius: 8, border: '1px solid #E5E7EB', background: '#fff', minWidth: 180 };
var controls = React.createElement('div', {
style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', padding: '12px 16px 8px 16px' }
}, [
React.createElement('span', { key: 'ml', style: label }, 'Metric:'),
React.createElement('select', { key: 'ms', value: metric, onChange: function (e) { setMetric(e.target.value); }, style: sel },
METRICS.map(function (m) { return React.createElement('option', { key: m, value: m }, m); })
),
React.createElement('span', { key: 'pl', style: Object.assign({}, label, { marginLeft: 8 }) }, 'Platform:'),
React.createElement('select', { key: 'ps', value: platform, onChange: function (e) { setPlatform(e.target.value); }, style: sel },
[React.createElement('option', { key: 'allp', value: 'All' }, 'All')].concat(
allPlatforms.map(function (p) { return React.createElement('option', { key: p, value: p }, p); })
)
),
React.createElement('span', { key: 'fl', style: Object.assign({}, label, { marginLeft: 8 }) }, 'Funnel:'),
React.createElement('select', { key: 'fs', value: funnel, onChange: function (e) { setFunnel(e.target.value); }, style: sel },
[React.createElement('option', { key: 'allf', value: 'All' }, 'All')].concat(
allFunnels.map(function (f) { return React.createElement('option', { key: f, value: f }, f); })
)
)
]);
// -------------------- Return --------------------
return React.createElement('div', { style: { width: '100%', position: 'relative' } }, [
controls,
React.createElement('div', { key: 'wrap', ref: wrapRef, style: { position: 'relative', width: '100%', minHeight: 520 } })
]);
}