Skip to main content
Skip table of contents

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:

CODE
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

state, region, us_state, state_name, state_code, geo

"CA" or "California"

Spend

media_spend, total_spend, cost

2300000

FTUs

ftus, total_ftu, cda_new_reg

1250

CAC (optional)

cac, cpa

183.45

Payback (optional)

payback_months, payback

2.3

Platform

platform, publisher_platform, harmonized_platform, channel_platform

"Meta"

Funnel

funnel, harmonized_funnel, stage

"Upper Funnel"

💡 If a client uses custom field names (e.g. geo_state instead of state), 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 cac)

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, or mo for Payback)

Example:

CODE
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:

CODE
var light = '#E9F5EC', mid = '#7BC47F', dark = '#1E7F3A';

🧭 Metric Defaults

Set the initial metric to whatever makes sense:

CODE
var [metric, setMetric] = useState('Spend');

🧩 Add or Remove Metrics

To add a new metric (e.g. “Impressions” or “Revenue”):

  1. Add the metric name to METRICS.

  2. Extend the aggregation logic in filteredAgg.

  3. 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 NAME_TO_CODE

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-svg or html2canvas.

  • Overlay comparison mode (use comparisonData input).

  • Replace dropdowns with external dashboard controls for global filters.


Fanatics Script Example:

CODE
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 } })
  ]);
}
JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.