Choropleth

A Choropleth Map of Regional Sales

A choropleth shades regions by value. The sample regions give units per country id, so a Map and a sequential scale make the join; countries without sales stay gray:

BookNest's units sold by country as a choropleth (sample data)HTML
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0"></script>
<script src="booknest-sales.js"></script>
<svg width="720" height="210" font-family="Arial" font-size="11"></svg>
<script type="module">
  const url = 'https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json';
  const topo = await d3.json(url), svg = d3.select('svg');
  const countries = topojson.feature(topo, topo.objects.countries);
  const units = new Map(SALES.regions.map(r => [r.id, r.units]));
  const color = d3.scaleSequentialSqrt([0, d3.max(units.values())], d3.interpolateBlues);
  const path = d3.geoPath(d3.geoEqualEarth().fitExtent([[0, 0], [540, 210]], countries));
  svg.append('g').selectAll('path').data(countries.features).join('path').attr('d', path)
    .attr('fill', d => units.has(d.id) ? color(units.get(d.id)) : '#eceff1')
    .attr('stroke', 'white').attr('stroke-width', 0.5)
    .append('title').text(d => `${d.properties.name}: ${units.get(d.id) ?? 0} units`);
  svg.append('g').selectAll('text').data(d3.sort(SALES.regions, r => -r.units)).join('text')
    .attr('x', 560).attr('y', (r, i) => 40 + i * 22).attr('fill', r => color(r.units))
    .text(r => `\u25a0 ${r.name} ${r.units}`);                     // legend
  window.__done = true;
</script>
Browser output of Listing 6.46
Browser output of 46

A square-root scale keeps mid-range countries apart when one dominates. Shade rates when populations differ, or big countries win by size alone.

A choropleth of BookNest's sample European sales, shaded with a square-root color scaleHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
  svg { width: 100%; max-width: 600px; display: block; background: #eef4f7; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0"></script>
<svg viewBox="0 0 600 300" font-size="11"></svg>
<p id="out">Hover a country.</p>
<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 };
  })();

  // Europe, simplified from world-atlas countries-110m.json (Natural Earth, public domain)
  const topo = {"type":"Topology","transform":{"scale":[0.03600036000360004,0.016925586033320103],"translate":[-180,-85.60903777459771]},"objects":{"countries":{"type":"GeometryCollection","geometries":[{"type":"Polygon","id":"578","properties":{"name":"Norway"},"arcs":[[-1,1,2,3]]},{"type":"MultiPolygon","id":"250","properties":{"name":"France"},"arcs":[[[4,5,6,7,8,9,10,11]],[[12]]]},{"type":"Polygon","id":"752","properties":{"name":"Sweden"},"arcs":[[-3,13,14]]},{"type":"Polygon","id":"616","properties":{"name":"Poland"},"arcs":[[-16,-17,17,18,19,20,-22,22]]},{"type":"Polygon","id":"040","properties":{"name":"Austria"},"arcs":[[23,24,25,26,27,28,29]]},{"type":"Polygon","id":"348","properties":{"name":"Hungary"},"arcs":[[-31,31,32,33,34,-24,35]]},{"type":"Polygon","id":"642","properties":{"name":"Romania"},"arcs":[[-37,37,38,39,-32,-41,-42]]},{"type":"Polygon","id":"276","properties":{"name":"Germany"},"arcs":[[-20,42,-28,43,-5,44,45,46,47,48,49]]},{"type":"Polygon","id":"100","properties":{"name":"Bulgaria"},"arcs":[[-39,50,51,52,53,54]]},{"type":"MultiPolygon","id":"300","properties":{"name":"Greece"},"arcs":[[[55]],[[-53,56,57,58,59]]]},{"type":"Polygon","id":"191","properties":{"name":"Croatia"},"arcs":[[-34,60,61,62,63,64]]},{"type":"Polygon","id":"756","properties":{"name":"Switzerland"},"arcs":[[-27,65,-6,-44]]},{"type":"Polygon","id":"442","properties":{"name":"Luxembourg"},"arcs":[[-45,-12,66]]},{"type":"Polygon","id":"056","properties":{"name":"Belgium"},"arcs":[[-46,-67,-11,67,68]]},{"type":"Polygon","id":"528","properties":{"name":"Netherlands"},"arcs":[[-47,-69,69]]},{"type":"Polygon","id":"620","properties":{"name":"Portugal"},"arcs":[[70,71]]},{"type":"Polygon","id":"724","properties":{"name":"Spain"},"arcs":[[-71,72,-9,73]]},{"type":"Polygon","id":"372","properties":{"name":"Ireland"},"arcs":[[74,75]]},{"type":"MultiPolygon","id":"380","properties":{"name":"Italy"},"arcs":[[[-26,76,77,-7,-66]],[[78]],[[79]]]},{"type":"MultiPolygon","id":"208","properties":{"name":"Denmark"},"arcs":[[[-49,80]],[[81]]]},{"type":"MultiPolygon","id":"826","properties":{"name":"United Kingdom"},"arcs":[[[-76,82]],[[83]]]},{"type":"Polygon","id":"705","properties":{"name":"Slovenia"},"arcs":[[-25,-35,-65,84,-77]]},{"type":"Polygon","id":"246","properties":{"name":"Finland"},"arcs":[[-86,86,-14,-2]]},{"type":"Polygon","id":"703","properties":{"name":"Slovakia"},"arcs":[[-88,-36,-30,88,-18]]},{"type":"Polygon","id":"203","properties":{"name":"Czechia"},"arcs":[[-19,-89,-29,-43]]}]}},"arcs":[[[5794,9139],[70,29]],[[5794,9139],[12,41],[-36,23],[-83,-89],[-114,27]],[[5573,9141],[-21,-41],[-52,9],[-34,-33],[-89,-234],[-28,1],[-7,-233],[-36,-75]],[[5306,8535],[-18,37],[-55,-69],[-37,-14],[-39,31],[-18,199],[153,149],[118,196],[123,119],[107,23],[42,49],[100,9],[87,-43],[-36,-16],[31,-37]],[[5172,7980],[53,-26],[-18,-82]],[[5207,7872],[-39,-53],[22,-44]],[[5190,7775],[17,-135]],[[5207,7640],[-26,-34],[-95,-3],[-3,-36]],[[5083,7567],[-74,7],[-62,50]],[[4947,7624],[20,153],[-95,157],[83,-2],[-9,67],[27,-25],[97,106]],[[5070,8080],[88,-96]],[[5158,7984],[14,-4]],[[5243,7577],[18,22],[-5,-96],[-13,74]],[[5573,9141],[81,-69],[10,-114]],[[5664,8958],[-48,-17],[-22,-77],[-98,-99],[-20,-83],[46,-74],[-55,-81],[-26,-154],[-34,5],[-15,-46],[-32,-3],[-54,206]],[[5654,8105],[-2,138]],[[5627,7958],[38,79],[-11,68]],[[5627,7958],[-103,24]],[[5524,7982],[-107,96]],[[5417,8078],[-25,156]],[[5392,8234],[98,65],[56,-25]],[[5631,8268],[-85,6]],[[5631,8268],[21,-25]],[[5472,7901],[-22,-75]],[[5450,7826],[-66,-20]],[[5384,7806],[-46,36],[-48,-13]],[[5290,7829],[-23,37]],[[5267,7866],[92,-3],[19,83]],[[5378,7946],[93,-17]],[[5471,7929],[1,-28]],[[5631,7887],[-18,32]],[[5631,7887],[-69,-104]],[[5562,7783],[-39,-13]],[[5523,7770],[-63,36]],[[5460,7806],[-10,20]],[[5472,7901],[24,-21],[82,51],[35,-12]],[[5822,7734],[-38,12]],[[5822,7734],[-29,-94]],[[5793,7640],[-36,28],[-47,-29],[-73,8],[-8,25]],[[5629,7672],[-67,111]],[[5739,7907],[-108,-20]],[[5784,7746],[-45,161]],[[5417,8078],[-77,-50],[38,-82]],[[5267,7866],[-60,6]],[[5172,7980],[-4,40]],[[5168,8020],[3,40]],[[5171,8060],[21,158]],[[5192,8218],[52,32],[-7,55]],[[5237,8305],[39,2]],[[5276,8307],[28,-58],[44,27],[44,-42]],[[5793,7640],[-15,-100]],[[5778,7540],[-53,-11]],[[5725,7529],[0,-29],[-87,0]],[[5638,7500],[-16,58]],[[5622,7558],[7,114]],[[5730,7144],[-43,-23],[-34,21],[5,26],[72,-24]],[[5725,7529],[-1,-59]],[[5724,7470],[-65,-8],[5,-43],[-35,17],[38,-120],[-34,-54],[10,-52],[-41,25],[-42,164]],[[5560,7399],[24,72]],[[5584,7471],[54,29]],[[5523,7770],[5,-62]],[[5528,7708],[-85,23],[-6,-25],[79,-128]],[[5516,7578],[-4,-10]],[[5512,7568],[-131,178]],[[5381,7746],[45,-3],[34,63]],[[5290,7829],[-41,-51],[-59,-3]],[[5158,7984],[10,36]],[[5070,8080],[22,12]],[[5092,8092],[46,7],[33,-39]],[[5092,8092],[39,103],[61,23]],[[4749,7532],[21,24],[53,-53],[-30,-253]],[[4793,7250],[-40,-14],[-18,111],[14,185]],[[4749,7532],[-10,68],[39,43],[169,-19]],[[5083,7567],[-25,-73],[-35,-13],[-20,-134],[-63,-122],[-61,0],[-28,-43],[-58,68]],[[4828,8241],[-17,-95],[-88,-26],[8,121],[59,74]],[[4790,8315],[0,-63],[38,-11]],[[5384,7806],[3,-54]],[[5387,7752],[-45,-13],[8,-76],[163,-232],[-5,-21],[-39,37],[-22,-145],[-19,122],[-117,136],[-28,93],[-36,26],[-40,-39]],[[5410,7312],[21,5],[-12,-95],[-74,58],[4,31],[61,1]],[[5242,7474],[14,19],[16,-42],[-27,-94],[-3,117]],[[5237,8305],[-8,109],[65,55],[-18,-162]],[[5344,8373],[-8,-77],[-33,58],[41,19]],[[4790,8315],[53,-34],[-15,-40]],[[4914,8213],[-85,200],[32,109],[56,0],[-30,-64],[59,8],[-33,-101],[29,-4],[71,-176],[34,-11],[-32,-117],[-97,-4],[-64,-43],[51,86],[-51,34],[29,18],[-10,71],[41,-6]],[[5381,7746],[6,6]],[[5780,8633],[95,139],[-41,41],[5,133],[-32,67],[26,45],[-43,39],[4,42]],[[5780,8633],[-145,-39],[-43,51],[6,146],[107,114],[-41,53]],[[5613,7919],[14,39]],[[5471,7929],[53,53]]]};

  const countries = topojson.feature(topo, topo.objects.countries);
  const units = new Map(SALES.regions.map(r => [r.id, r.units]));             // join on the country id
  const color = d3.scaleSequentialSqrt([0, d3.max(units.values())], d3.interpolateBlues);
  const path = d3.geoPath(d3.geoConicEqualArea().parallels([43, 62]).rotate([-10, 0])
    .fitExtent([[0, 5], [420, 295]], countries));
  const svg = d3.select('svg');
  svg.append('g').selectAll('path').data(countries.features).join('path').attr('d', path)
    .attr('fill', d => units.has(d.id) ? color(units.get(d.id)) : '#e0e4e6')   // no sales: gray
    .attr('stroke', 'white').attr('stroke-width', 0.6)
    .on('pointerenter', function (e, d) {
      d3.select(this).attr('stroke', '#263238').raise();
      d3.select('#out').text(`${d.properties.name}: ${units.get(d.id) ?? 0} units`);
    })
    .on('pointerleave', function () { d3.select(this).attr('stroke', 'white'); });

  // Legend: the six countries, sorted, plus a continuous ramp with an axis
  svg.append('g').selectAll('text').data(d3.sort(SALES.regions, r => -r.units)).join('text')
    .attr('x', 440).attr('y', (r, i) => 30 + i * 22).attr('fill', r => color(r.units)).attr('font-weight', 'bold')
    .text(r => `■ ${r.name} ${r.units}`).attr('stroke', '#263238').attr('stroke-width', 0.2);
  const ramp = svg.append('g').attr('transform', 'translate(440,190)');
  ramp.selectAll('rect').data(d3.range(0, 1, 0.02)).join('rect').attr('x', t => t * 140).attr('width', 3)
    .attr('height', 12).attr('fill', t => color(t * color.domain()[1]));
  ramp.append('g').attr('transform', 'translate(0,12)')
    .call(d3.axisBottom(d3.scaleSqrt(color.domain(), [0, 140])).ticks(3));
  ramp.append('text').attr('y', -6).text('units (sqrt scale)');
</script>